@canarygate/sdk 0.1.1 → 0.1.3
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 +6 -5
- package/dist/{canary-gate-base-DzuUUPOC.d.mts → canary-gate-base-iXS3mVyK.d.mts} +1 -0
- package/dist/{canary-gate-base-DzuUUPOC.d.ts → canary-gate-base-iXS3mVyK.d.ts} +1 -0
- package/dist/canary-gate-base-jk1htbug.d.mts +71 -0
- package/dist/canary-gate-base-jk1htbug.d.ts +71 -0
- package/dist/{chunk-ECO47H4R.mjs → chunk-LMPIWD3Z.mjs} +7 -0
- package/dist/chunk-X2JAB5XL.mjs +341 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +27 -4
- package/dist/client.mjs +13 -1
- package/dist/server.d.mts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +20 -4
- package/dist/server.mjs +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,14 +65,15 @@ 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`.
|
|
69
|
+
|
|
68
70
|
| Option | Type | Default | Description |
|
|
69
71
|
| -------------------- | --------- | ----------------------- | ------------------------------------------------ |
|
|
70
|
-
| `baseUrl` | `string` | `http://localhost:3001` | CanaryGate API base URL |
|
|
71
72
|
| `environment` | `string` | — | Environment to evaluate flags against |
|
|
72
|
-
| `pollIntervalMs` | `number` | `30000` | Browser polling interval in ms. Set `0` to disable |
|
|
73
|
-
| `reconnectDelay` | `number` | `5000` | Initial SSE reconnect delay (ms)
|
|
74
|
-
| `maxReconnectDelay` | `number` | `30000` | Reconnect delay cap, exponential backoff (ms)
|
|
75
|
-
| `heartbeatTimeoutMs` | `number` | `65000` | Silence window before treating the stream as dead |
|
|
73
|
+
| `pollIntervalMs` | `number` | `30000` | Browser polling interval in ms (clamped to a 3000ms minimum). Set `0` to disable. Throws if passed to the server entry |
|
|
74
|
+
| `reconnectDelay` | `number` | `5000` | Initial SSE reconnect delay (ms). Server-only; throws on the browser entry |
|
|
75
|
+
| `maxReconnectDelay` | `number` | `30000` | Reconnect delay cap, exponential backoff (ms). Server-only |
|
|
76
|
+
| `heartbeatTimeoutMs` | `number` | `65000` | Silence window before treating the stream as dead. Server-only |
|
|
76
77
|
|
|
77
78
|
## Methods
|
|
78
79
|
|
|
@@ -16,6 +16,7 @@ type FlagEvaluationContext = {
|
|
|
16
16
|
type CanaryGateOptions = {
|
|
17
17
|
baseUrl?: string;
|
|
18
18
|
environment?: string;
|
|
19
|
+
/** Browser polling interval in ms. Default: 30000; minimum enforced: 3000. Set 0 to disable. Server always uses SSE instead. */
|
|
19
20
|
pollIntervalMs?: number;
|
|
20
21
|
reconnectDelay?: number;
|
|
21
22
|
maxReconnectDelay?: number;
|
|
@@ -16,6 +16,7 @@ type FlagEvaluationContext = {
|
|
|
16
16
|
type CanaryGateOptions = {
|
|
17
17
|
baseUrl?: string;
|
|
18
18
|
environment?: string;
|
|
19
|
+
/** Browser polling interval in ms. Default: 30000; minimum enforced: 3000. Set 0 to disable. Server always uses SSE instead. */
|
|
19
20
|
pollIntervalMs?: number;
|
|
20
21
|
reconnectDelay?: number;
|
|
21
22
|
maxReconnectDelay?: number;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
type BooleanFlagData = {
|
|
2
|
+
key: string;
|
|
3
|
+
type: 'boolean';
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
};
|
|
6
|
+
type RolloutFlagData = {
|
|
7
|
+
key: string;
|
|
8
|
+
type: 'rollout';
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
percent: number;
|
|
11
|
+
};
|
|
12
|
+
type FlagData = BooleanFlagData | RolloutFlagData;
|
|
13
|
+
type FlagEvaluationContext = {
|
|
14
|
+
userId?: string;
|
|
15
|
+
};
|
|
16
|
+
type CanaryGateOptions = {
|
|
17
|
+
environment?: string;
|
|
18
|
+
/** Browser polling interval in ms. Default: 30000; minimum enforced: 3000. Set 0 to disable. Throws if set on the server entry. */
|
|
19
|
+
pollIntervalMs?: number;
|
|
20
|
+
/** Initial stream reconnect delay in ms. Default: 5000. Server-only. */
|
|
21
|
+
reconnectDelay?: number;
|
|
22
|
+
/** Maximum stream reconnect delay in ms. Default: 30000. Server-only. */
|
|
23
|
+
maxReconnectDelay?: number;
|
|
24
|
+
/** Time in ms without a server heartbeat before reconnecting the stream. Default: 65000. Server-only. */
|
|
25
|
+
heartbeatTimeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
declare class CanaryGateBase {
|
|
29
|
+
protected readonly apiKey: string;
|
|
30
|
+
protected readonly streamEnabled: boolean;
|
|
31
|
+
protected readonly anonIdFactory: () => string;
|
|
32
|
+
private readonly baseUrl;
|
|
33
|
+
private readonly environment;
|
|
34
|
+
private readonly reconnectDelay;
|
|
35
|
+
private readonly maxReconnectDelay;
|
|
36
|
+
private readonly heartbeatTimeoutMs;
|
|
37
|
+
private readonly pollIntervalMs;
|
|
38
|
+
private cache;
|
|
39
|
+
private cacheVersions;
|
|
40
|
+
private readonly anonId;
|
|
41
|
+
private streamAbortController;
|
|
42
|
+
private reconnectTimeout;
|
|
43
|
+
private heartbeatTimeout;
|
|
44
|
+
private pollTimeout;
|
|
45
|
+
private streamRetryDelay;
|
|
46
|
+
private reconnectAttempts;
|
|
47
|
+
private stale;
|
|
48
|
+
private lastSyncAt;
|
|
49
|
+
private destroyed;
|
|
50
|
+
constructor(apiKey: string, options: CanaryGateOptions | undefined, streamEnabled: boolean, anonIdFactory: () => string);
|
|
51
|
+
init(): Promise<void>;
|
|
52
|
+
private replaceCacheFromSnapshot;
|
|
53
|
+
private fetchFlags;
|
|
54
|
+
private applyFlagUpdate;
|
|
55
|
+
private applyFlagDeletion;
|
|
56
|
+
private handleStreamMessage;
|
|
57
|
+
private clearHeartbeatTimeout;
|
|
58
|
+
private bumpHeartbeatTimeout;
|
|
59
|
+
private scheduleReconnect;
|
|
60
|
+
private consumeStream;
|
|
61
|
+
private connectStream;
|
|
62
|
+
private startPolling;
|
|
63
|
+
private stopPolling;
|
|
64
|
+
getFlag(key: string, context?: FlagEvaluationContext): FlagData | undefined;
|
|
65
|
+
getFlags(context?: FlagEvaluationContext): FlagData[];
|
|
66
|
+
isStale(): boolean;
|
|
67
|
+
getLastSyncAt(): string | null;
|
|
68
|
+
disconnect(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { CanaryGateBase as C, type CanaryGateOptions as a };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
type BooleanFlagData = {
|
|
2
|
+
key: string;
|
|
3
|
+
type: 'boolean';
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
};
|
|
6
|
+
type RolloutFlagData = {
|
|
7
|
+
key: string;
|
|
8
|
+
type: 'rollout';
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
percent: number;
|
|
11
|
+
};
|
|
12
|
+
type FlagData = BooleanFlagData | RolloutFlagData;
|
|
13
|
+
type FlagEvaluationContext = {
|
|
14
|
+
userId?: string;
|
|
15
|
+
};
|
|
16
|
+
type CanaryGateOptions = {
|
|
17
|
+
environment?: string;
|
|
18
|
+
/** Browser polling interval in ms. Default: 30000; minimum enforced: 3000. Set 0 to disable. Throws if set on the server entry. */
|
|
19
|
+
pollIntervalMs?: number;
|
|
20
|
+
/** Initial stream reconnect delay in ms. Default: 5000. Server-only. */
|
|
21
|
+
reconnectDelay?: number;
|
|
22
|
+
/** Maximum stream reconnect delay in ms. Default: 30000. Server-only. */
|
|
23
|
+
maxReconnectDelay?: number;
|
|
24
|
+
/** Time in ms without a server heartbeat before reconnecting the stream. Default: 65000. Server-only. */
|
|
25
|
+
heartbeatTimeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
declare class CanaryGateBase {
|
|
29
|
+
protected readonly apiKey: string;
|
|
30
|
+
protected readonly streamEnabled: boolean;
|
|
31
|
+
protected readonly anonIdFactory: () => string;
|
|
32
|
+
private readonly baseUrl;
|
|
33
|
+
private readonly environment;
|
|
34
|
+
private readonly reconnectDelay;
|
|
35
|
+
private readonly maxReconnectDelay;
|
|
36
|
+
private readonly heartbeatTimeoutMs;
|
|
37
|
+
private readonly pollIntervalMs;
|
|
38
|
+
private cache;
|
|
39
|
+
private cacheVersions;
|
|
40
|
+
private readonly anonId;
|
|
41
|
+
private streamAbortController;
|
|
42
|
+
private reconnectTimeout;
|
|
43
|
+
private heartbeatTimeout;
|
|
44
|
+
private pollTimeout;
|
|
45
|
+
private streamRetryDelay;
|
|
46
|
+
private reconnectAttempts;
|
|
47
|
+
private stale;
|
|
48
|
+
private lastSyncAt;
|
|
49
|
+
private destroyed;
|
|
50
|
+
constructor(apiKey: string, options: CanaryGateOptions | undefined, streamEnabled: boolean, anonIdFactory: () => string);
|
|
51
|
+
init(): Promise<void>;
|
|
52
|
+
private replaceCacheFromSnapshot;
|
|
53
|
+
private fetchFlags;
|
|
54
|
+
private applyFlagUpdate;
|
|
55
|
+
private applyFlagDeletion;
|
|
56
|
+
private handleStreamMessage;
|
|
57
|
+
private clearHeartbeatTimeout;
|
|
58
|
+
private bumpHeartbeatTimeout;
|
|
59
|
+
private scheduleReconnect;
|
|
60
|
+
private consumeStream;
|
|
61
|
+
private connectStream;
|
|
62
|
+
private startPolling;
|
|
63
|
+
private stopPolling;
|
|
64
|
+
getFlag(key: string, context?: FlagEvaluationContext): FlagData | undefined;
|
|
65
|
+
getFlags(context?: FlagEvaluationContext): FlagData[];
|
|
66
|
+
isStale(): boolean;
|
|
67
|
+
getLastSyncAt(): string | null;
|
|
68
|
+
disconnect(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { CanaryGateBase as C, type CanaryGateOptions as a };
|
|
@@ -43,6 +43,7 @@ function parseSseEventBlock(block) {
|
|
|
43
43
|
var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
|
|
44
44
|
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
|
|
45
45
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
46
|
+
var MIN_POLL_INTERVAL_MS = 3e3;
|
|
46
47
|
function isAbortError(error) {
|
|
47
48
|
return error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
|
|
48
49
|
}
|
|
@@ -77,6 +78,12 @@ var CanaryGateBase = class {
|
|
|
77
78
|
);
|
|
78
79
|
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
79
80
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
81
|
+
if (options.pollIntervalMs !== void 0 && options.pollIntervalMs > 0 && options.pollIntervalMs < MIN_POLL_INTERVAL_MS) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[canarygate] pollIntervalMs must be at least ${MIN_POLL_INTERVAL_MS}ms; clamped to ${MIN_POLL_INTERVAL_MS}.`
|
|
84
|
+
);
|
|
85
|
+
this.pollIntervalMs = MIN_POLL_INTERVAL_MS;
|
|
86
|
+
}
|
|
80
87
|
this.streamRetryDelay = this.reconnectDelay;
|
|
81
88
|
this.anonId = anonIdFactory();
|
|
82
89
|
}
|
|
@@ -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 = "http://localhost:3001";
|
|
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.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-
|
|
1
|
+
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-jk1htbug.mjs';
|
|
2
2
|
|
|
3
3
|
declare class CanaryGate extends CanaryGateBase {
|
|
4
4
|
constructor(apiKey: string, options?: CanaryGateOptions);
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-
|
|
1
|
+
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-jk1htbug.js';
|
|
2
2
|
|
|
3
3
|
declare class CanaryGate extends CanaryGateBase {
|
|
4
4
|
constructor(apiKey: string, options?: CanaryGateOptions);
|
package/dist/client.js
CHANGED
|
@@ -69,6 +69,14 @@ function parseSseEventBlock(block) {
|
|
|
69
69
|
var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
|
|
70
70
|
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
|
|
71
71
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
72
|
+
var MIN_POLL_INTERVAL_MS = 3e3;
|
|
73
|
+
var DEFAULT_BASE_URL = "http://localhost:3001";
|
|
74
|
+
function resolveBaseUrl() {
|
|
75
|
+
if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
|
|
76
|
+
return (process.env.CANARYGATE_BASE_URL ?? process.env.NEXT_PUBLIC_CANARYGATE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
77
|
+
}
|
|
78
|
+
return DEFAULT_BASE_URL;
|
|
79
|
+
}
|
|
72
80
|
function isAbortError(error) {
|
|
73
81
|
return error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
|
|
74
82
|
}
|
|
@@ -91,10 +99,7 @@ var CanaryGateBase = class {
|
|
|
91
99
|
this.stale = false;
|
|
92
100
|
this.lastSyncAt = null;
|
|
93
101
|
this.destroyed = false;
|
|
94
|
-
this.baseUrl = (
|
|
95
|
-
/\/$/,
|
|
96
|
-
""
|
|
97
|
-
);
|
|
102
|
+
this.baseUrl = resolveBaseUrl();
|
|
98
103
|
this.environment = options.environment;
|
|
99
104
|
this.reconnectDelay = options.reconnectDelay ?? 5e3;
|
|
100
105
|
this.maxReconnectDelay = Math.max(
|
|
@@ -103,6 +108,12 @@ var CanaryGateBase = class {
|
|
|
103
108
|
);
|
|
104
109
|
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
105
110
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
111
|
+
if (options.pollIntervalMs !== void 0 && options.pollIntervalMs > 0 && options.pollIntervalMs < MIN_POLL_INTERVAL_MS) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`[canarygate] pollIntervalMs must be at least ${MIN_POLL_INTERVAL_MS}ms; clamped to ${MIN_POLL_INTERVAL_MS}.`
|
|
114
|
+
);
|
|
115
|
+
this.pollIntervalMs = MIN_POLL_INTERVAL_MS;
|
|
116
|
+
}
|
|
106
117
|
this.streamRetryDelay = this.reconnectDelay;
|
|
107
118
|
this.anonId = anonIdFactory();
|
|
108
119
|
}
|
|
@@ -353,6 +364,11 @@ var CanaryGateBase = class {
|
|
|
353
364
|
|
|
354
365
|
// src/client.ts
|
|
355
366
|
var ANON_ID_KEY = "__cg_anon_id__";
|
|
367
|
+
var SERVER_ONLY_OPTIONS = [
|
|
368
|
+
"reconnectDelay",
|
|
369
|
+
"maxReconnectDelay",
|
|
370
|
+
"heartbeatTimeoutMs"
|
|
371
|
+
];
|
|
356
372
|
function getOrCreateAnonId() {
|
|
357
373
|
if (typeof localStorage !== "undefined") {
|
|
358
374
|
const stored = localStorage.getItem(ANON_ID_KEY);
|
|
@@ -370,6 +386,13 @@ var CanaryGate = class extends CanaryGateBase {
|
|
|
370
386
|
'@canarygate/sdk/client is browser-only. On server runtimes (Node.js, Deno, Bun, Edge) import from "@canarygate/sdk/server" instead.'
|
|
371
387
|
);
|
|
372
388
|
}
|
|
389
|
+
for (const key of SERVER_ONLY_OPTIONS) {
|
|
390
|
+
if (options[key] !== void 0) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`"${key}" is a server-only option and has no effect on @canarygate/sdk/client. Remove it, or use @canarygate/sdk/server.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
373
396
|
super(apiKey, options, false, getOrCreateAnonId);
|
|
374
397
|
}
|
|
375
398
|
};
|
package/dist/client.mjs
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanaryGateBase
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-X2JAB5XL.mjs";
|
|
4
4
|
|
|
5
5
|
// src/client.ts
|
|
6
6
|
var ANON_ID_KEY = "__cg_anon_id__";
|
|
7
|
+
var SERVER_ONLY_OPTIONS = [
|
|
8
|
+
"reconnectDelay",
|
|
9
|
+
"maxReconnectDelay",
|
|
10
|
+
"heartbeatTimeoutMs"
|
|
11
|
+
];
|
|
7
12
|
function getOrCreateAnonId() {
|
|
8
13
|
if (typeof localStorage !== "undefined") {
|
|
9
14
|
const stored = localStorage.getItem(ANON_ID_KEY);
|
|
@@ -21,6 +26,13 @@ var CanaryGate = class extends CanaryGateBase {
|
|
|
21
26
|
'@canarygate/sdk/client is browser-only. On server runtimes (Node.js, Deno, Bun, Edge) import from "@canarygate/sdk/server" instead.'
|
|
22
27
|
);
|
|
23
28
|
}
|
|
29
|
+
for (const key of SERVER_ONLY_OPTIONS) {
|
|
30
|
+
if (options[key] !== void 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`"${key}" is a server-only option and has no effect on @canarygate/sdk/client. Remove it, or use @canarygate/sdk/server.`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
24
36
|
super(apiKey, options, false, getOrCreateAnonId);
|
|
25
37
|
}
|
|
26
38
|
};
|
package/dist/server.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-
|
|
1
|
+
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-jk1htbug.mjs';
|
|
2
2
|
|
|
3
3
|
declare class CanaryGate extends CanaryGateBase {
|
|
4
4
|
constructor(apiKey: string, options?: CanaryGateOptions);
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-
|
|
1
|
+
import { C as CanaryGateBase, a as CanaryGateOptions } from './canary-gate-base-jk1htbug.js';
|
|
2
2
|
|
|
3
3
|
declare class CanaryGate extends CanaryGateBase {
|
|
4
4
|
constructor(apiKey: string, options?: CanaryGateOptions);
|
package/dist/server.js
CHANGED
|
@@ -69,6 +69,14 @@ function parseSseEventBlock(block) {
|
|
|
69
69
|
var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
|
|
70
70
|
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
|
|
71
71
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
72
|
+
var MIN_POLL_INTERVAL_MS = 3e3;
|
|
73
|
+
var DEFAULT_BASE_URL = "http://localhost:3001";
|
|
74
|
+
function resolveBaseUrl() {
|
|
75
|
+
if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
|
|
76
|
+
return (process.env.CANARYGATE_BASE_URL ?? process.env.NEXT_PUBLIC_CANARYGATE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
77
|
+
}
|
|
78
|
+
return DEFAULT_BASE_URL;
|
|
79
|
+
}
|
|
72
80
|
function isAbortError(error) {
|
|
73
81
|
return error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
|
|
74
82
|
}
|
|
@@ -91,10 +99,7 @@ var CanaryGateBase = class {
|
|
|
91
99
|
this.stale = false;
|
|
92
100
|
this.lastSyncAt = null;
|
|
93
101
|
this.destroyed = false;
|
|
94
|
-
this.baseUrl = (
|
|
95
|
-
/\/$/,
|
|
96
|
-
""
|
|
97
|
-
);
|
|
102
|
+
this.baseUrl = resolveBaseUrl();
|
|
98
103
|
this.environment = options.environment;
|
|
99
104
|
this.reconnectDelay = options.reconnectDelay ?? 5e3;
|
|
100
105
|
this.maxReconnectDelay = Math.max(
|
|
@@ -103,6 +108,12 @@ var CanaryGateBase = class {
|
|
|
103
108
|
);
|
|
104
109
|
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
105
110
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
111
|
+
if (options.pollIntervalMs !== void 0 && options.pollIntervalMs > 0 && options.pollIntervalMs < MIN_POLL_INTERVAL_MS) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`[canarygate] pollIntervalMs must be at least ${MIN_POLL_INTERVAL_MS}ms; clamped to ${MIN_POLL_INTERVAL_MS}.`
|
|
114
|
+
);
|
|
115
|
+
this.pollIntervalMs = MIN_POLL_INTERVAL_MS;
|
|
116
|
+
}
|
|
106
117
|
this.streamRetryDelay = this.reconnectDelay;
|
|
107
118
|
this.anonId = anonIdFactory();
|
|
108
119
|
}
|
|
@@ -359,6 +370,11 @@ var CanaryGate = class extends CanaryGateBase {
|
|
|
359
370
|
'@canarygate/sdk/server is server-only. In browsers import from "@canarygate/sdk/client" instead.'
|
|
360
371
|
);
|
|
361
372
|
}
|
|
373
|
+
if (options.pollIntervalMs !== void 0) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
'"pollIntervalMs" is a browser-only option and has no effect on @canarygate/sdk/server. Remove it, or use @canarygate/sdk/client.'
|
|
376
|
+
);
|
|
377
|
+
}
|
|
362
378
|
super(apiKey, options, true, () => crypto.randomUUID());
|
|
363
379
|
}
|
|
364
380
|
};
|
package/dist/server.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanaryGateBase
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-X2JAB5XL.mjs";
|
|
4
4
|
|
|
5
5
|
// src/server.ts
|
|
6
6
|
var CanaryGate = class extends CanaryGateBase {
|
|
@@ -10,6 +10,11 @@ var CanaryGate = class extends CanaryGateBase {
|
|
|
10
10
|
'@canarygate/sdk/server is server-only. In browsers import from "@canarygate/sdk/client" instead.'
|
|
11
11
|
);
|
|
12
12
|
}
|
|
13
|
+
if (options.pollIntervalMs !== void 0) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'"pollIntervalMs" is a browser-only option and has no effect on @canarygate/sdk/server. Remove it, or use @canarygate/sdk/client.'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
13
18
|
super(apiKey, options, true, () => crypto.randomUUID());
|
|
14
19
|
}
|
|
15
20
|
};
|