@togglerino/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/dist/index.d.mts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +392 -0
- package/dist/index.mjs +365 -0
- package/package.json +27 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the Togglerino SDK client.
|
|
3
|
+
*/
|
|
4
|
+
interface TogglerinoConfig {
|
|
5
|
+
/** Base URL of the Togglerino server (e.g. "http://localhost:8080"). */
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
/** SDK key for authenticating with the server. */
|
|
8
|
+
sdkKey: string;
|
|
9
|
+
/** Optional evaluation context (user, attributes). */
|
|
10
|
+
context?: EvaluationContext;
|
|
11
|
+
/**
|
|
12
|
+
* Whether to use SSE streaming for real-time flag updates.
|
|
13
|
+
* Falls back to polling if SSE connection fails.
|
|
14
|
+
* @default true
|
|
15
|
+
*/
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Polling interval in milliseconds. Used when streaming is disabled
|
|
19
|
+
* or as a fallback when SSE connection fails.
|
|
20
|
+
* @default 30000
|
|
21
|
+
*/
|
|
22
|
+
pollingInterval?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Context passed to the server for flag evaluation (targeting rules).
|
|
26
|
+
*/
|
|
27
|
+
interface EvaluationContext {
|
|
28
|
+
/** Unique user identifier. Maps to "user_id" on the server. */
|
|
29
|
+
userId?: string;
|
|
30
|
+
/** Arbitrary attributes for targeting (e.g. { plan: "pro", country: "US" }). */
|
|
31
|
+
attributes?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Result of evaluating a single flag.
|
|
35
|
+
*/
|
|
36
|
+
interface EvaluationResult {
|
|
37
|
+
value: unknown;
|
|
38
|
+
variant: string;
|
|
39
|
+
reason: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* SSE event emitted when a flag changes.
|
|
43
|
+
*/
|
|
44
|
+
interface FlagChangeEvent {
|
|
45
|
+
flagKey: string;
|
|
46
|
+
value: unknown;
|
|
47
|
+
variant: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* SSE event emitted when a flag is deleted.
|
|
51
|
+
*/
|
|
52
|
+
interface FlagDeletedEvent {
|
|
53
|
+
flagKey: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Events emitted by the Togglerino client.
|
|
57
|
+
* - "ready": fired after initial flag fetch completes.
|
|
58
|
+
* - "change": fired when a flag value changes (via SSE or polling).
|
|
59
|
+
* - "deleted": fired when a flag is deleted (via SSE). Payload is FlagDeletedEvent.
|
|
60
|
+
* - "context_change": fired after updateContext() completes. Payload is EvaluationContext.
|
|
61
|
+
* - "error": fired on fetch/SSE errors.
|
|
62
|
+
* - "reconnecting": fired when scheduling an SSE reconnection attempt. Payload: { attempt: number, delay: number }.
|
|
63
|
+
* - "reconnected": fired when SSE successfully reconnects after a disconnection.
|
|
64
|
+
*/
|
|
65
|
+
type EventType = 'change' | 'deleted' | 'context_change' | 'error' | 'ready' | 'reconnecting' | 'reconnected';
|
|
66
|
+
|
|
67
|
+
type Listener = (...args: any[]) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Togglerino SDK client.
|
|
70
|
+
*
|
|
71
|
+
* Usage:
|
|
72
|
+
* ```ts
|
|
73
|
+
* const client = new Togglerino({
|
|
74
|
+
* serverUrl: 'http://localhost:8080',
|
|
75
|
+
* sdkKey: 'sdk_abc123',
|
|
76
|
+
* context: { userId: 'user-42' },
|
|
77
|
+
* })
|
|
78
|
+
*
|
|
79
|
+
* await client.initialize()
|
|
80
|
+
*
|
|
81
|
+
* if (client.getBool('dark-mode')) {
|
|
82
|
+
* enableDarkMode()
|
|
83
|
+
* }
|
|
84
|
+
*
|
|
85
|
+
* client.on('change', (event) => {
|
|
86
|
+
* console.log('flag changed:', event.flagKey, event.value)
|
|
87
|
+
* })
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare class Togglerino {
|
|
91
|
+
private config;
|
|
92
|
+
private flags;
|
|
93
|
+
private listeners;
|
|
94
|
+
private pollTimer;
|
|
95
|
+
private sseAbortController;
|
|
96
|
+
private initialized;
|
|
97
|
+
private sseRetryCount;
|
|
98
|
+
private sseRetryTimeout;
|
|
99
|
+
private readonly maxRetryDelay;
|
|
100
|
+
constructor(config: TogglerinoConfig);
|
|
101
|
+
/**
|
|
102
|
+
* Initialize the client: fetch all flags and start listening for updates.
|
|
103
|
+
* Must be called before reading any flag values.
|
|
104
|
+
*/
|
|
105
|
+
initialize(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Get a boolean flag value.
|
|
108
|
+
* Returns `defaultValue` if the flag is not found or is not a boolean.
|
|
109
|
+
*/
|
|
110
|
+
getBool(key: string, defaultValue?: boolean): boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Get a string flag value.
|
|
113
|
+
* Returns `defaultValue` if the flag is not found or is not a string.
|
|
114
|
+
*/
|
|
115
|
+
getString(key: string, defaultValue?: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Get a numeric flag value.
|
|
118
|
+
* Returns `defaultValue` if the flag is not found or is not a number.
|
|
119
|
+
*/
|
|
120
|
+
getNumber(key: string, defaultValue?: number): number;
|
|
121
|
+
/**
|
|
122
|
+
* Get a JSON flag value (object, array, etc.).
|
|
123
|
+
* Returns `defaultValue` if the flag is not found.
|
|
124
|
+
*/
|
|
125
|
+
getJson<T = unknown>(key: string, defaultValue?: T): T;
|
|
126
|
+
/**
|
|
127
|
+
* Get the raw EvaluationResult for a flag.
|
|
128
|
+
* Returns undefined if the flag is not found.
|
|
129
|
+
*/
|
|
130
|
+
getDetail(key: string): EvaluationResult | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Subscribe to SDK events.
|
|
133
|
+
* Returns an unsubscribe function.
|
|
134
|
+
*
|
|
135
|
+
* Events:
|
|
136
|
+
* - "ready": no payload, fired after initialize() completes.
|
|
137
|
+
* - "change": payload is FlagChangeEvent.
|
|
138
|
+
* - "context_change": payload is EvaluationContext, fired after updateContext() completes.
|
|
139
|
+
* - "error": payload is Error.
|
|
140
|
+
* - "reconnecting": payload is { attempt: number, delay: number }, fired when scheduling SSE reconnect.
|
|
141
|
+
* - "reconnected": no payload, fired when SSE successfully reconnects after a disconnection.
|
|
142
|
+
*/
|
|
143
|
+
on(event: EventType, listener: Listener): () => void;
|
|
144
|
+
/**
|
|
145
|
+
* Get the current evaluation context.
|
|
146
|
+
*/
|
|
147
|
+
getContext(): EvaluationContext;
|
|
148
|
+
/**
|
|
149
|
+
* Update the evaluation context and re-fetch all flags.
|
|
150
|
+
* Useful when the user logs in / changes attributes.
|
|
151
|
+
*/
|
|
152
|
+
updateContext(context: Partial<EvaluationContext>): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* Stop all background activity (SSE stream, polling) and remove listeners.
|
|
155
|
+
* Call this when the client is no longer needed.
|
|
156
|
+
*/
|
|
157
|
+
close(): void;
|
|
158
|
+
/**
|
|
159
|
+
* Fetch all flags from the server evaluation endpoint.
|
|
160
|
+
*/
|
|
161
|
+
private fetchFlags;
|
|
162
|
+
/**
|
|
163
|
+
* Calculate the next retry delay using exponential backoff.
|
|
164
|
+
* Sequence: 1s, 2s, 4s, 8s, 16s, 30s (capped).
|
|
165
|
+
*/
|
|
166
|
+
private getRetryDelay;
|
|
167
|
+
/**
|
|
168
|
+
* Schedule an SSE reconnection attempt with exponential backoff.
|
|
169
|
+
* Starts polling as a fallback while retrying.
|
|
170
|
+
*/
|
|
171
|
+
private scheduleSSEReconnect;
|
|
172
|
+
/**
|
|
173
|
+
* Start an SSE connection using fetch + ReadableStream.
|
|
174
|
+
* This allows us to send an Authorization header (unlike native EventSource).
|
|
175
|
+
* On failure, schedules a reconnection attempt with exponential backoff
|
|
176
|
+
* and uses polling as a fallback in the meantime.
|
|
177
|
+
*/
|
|
178
|
+
private startSSE;
|
|
179
|
+
/**
|
|
180
|
+
* Read and parse SSE events from a ReadableStream.
|
|
181
|
+
* SSE format:
|
|
182
|
+
* event: flag_update
|
|
183
|
+
* data: {"flagKey":"dark-mode","value":true,"variant":"on"}
|
|
184
|
+
*
|
|
185
|
+
*/
|
|
186
|
+
private processSSEStream;
|
|
187
|
+
/**
|
|
188
|
+
* Parse a single SSE event block and update flags accordingly.
|
|
189
|
+
*/
|
|
190
|
+
private handleSSEEvent;
|
|
191
|
+
/**
|
|
192
|
+
* Start periodic polling as a fallback when SSE is unavailable.
|
|
193
|
+
*/
|
|
194
|
+
private startPolling;
|
|
195
|
+
private emit;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { type EvaluationContext, type EvaluationResult, type EventType, type FlagChangeEvent, type FlagDeletedEvent, Togglerino, type TogglerinoConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the Togglerino SDK client.
|
|
3
|
+
*/
|
|
4
|
+
interface TogglerinoConfig {
|
|
5
|
+
/** Base URL of the Togglerino server (e.g. "http://localhost:8080"). */
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
/** SDK key for authenticating with the server. */
|
|
8
|
+
sdkKey: string;
|
|
9
|
+
/** Optional evaluation context (user, attributes). */
|
|
10
|
+
context?: EvaluationContext;
|
|
11
|
+
/**
|
|
12
|
+
* Whether to use SSE streaming for real-time flag updates.
|
|
13
|
+
* Falls back to polling if SSE connection fails.
|
|
14
|
+
* @default true
|
|
15
|
+
*/
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Polling interval in milliseconds. Used when streaming is disabled
|
|
19
|
+
* or as a fallback when SSE connection fails.
|
|
20
|
+
* @default 30000
|
|
21
|
+
*/
|
|
22
|
+
pollingInterval?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Context passed to the server for flag evaluation (targeting rules).
|
|
26
|
+
*/
|
|
27
|
+
interface EvaluationContext {
|
|
28
|
+
/** Unique user identifier. Maps to "user_id" on the server. */
|
|
29
|
+
userId?: string;
|
|
30
|
+
/** Arbitrary attributes for targeting (e.g. { plan: "pro", country: "US" }). */
|
|
31
|
+
attributes?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Result of evaluating a single flag.
|
|
35
|
+
*/
|
|
36
|
+
interface EvaluationResult {
|
|
37
|
+
value: unknown;
|
|
38
|
+
variant: string;
|
|
39
|
+
reason: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* SSE event emitted when a flag changes.
|
|
43
|
+
*/
|
|
44
|
+
interface FlagChangeEvent {
|
|
45
|
+
flagKey: string;
|
|
46
|
+
value: unknown;
|
|
47
|
+
variant: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* SSE event emitted when a flag is deleted.
|
|
51
|
+
*/
|
|
52
|
+
interface FlagDeletedEvent {
|
|
53
|
+
flagKey: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Events emitted by the Togglerino client.
|
|
57
|
+
* - "ready": fired after initial flag fetch completes.
|
|
58
|
+
* - "change": fired when a flag value changes (via SSE or polling).
|
|
59
|
+
* - "deleted": fired when a flag is deleted (via SSE). Payload is FlagDeletedEvent.
|
|
60
|
+
* - "context_change": fired after updateContext() completes. Payload is EvaluationContext.
|
|
61
|
+
* - "error": fired on fetch/SSE errors.
|
|
62
|
+
* - "reconnecting": fired when scheduling an SSE reconnection attempt. Payload: { attempt: number, delay: number }.
|
|
63
|
+
* - "reconnected": fired when SSE successfully reconnects after a disconnection.
|
|
64
|
+
*/
|
|
65
|
+
type EventType = 'change' | 'deleted' | 'context_change' | 'error' | 'ready' | 'reconnecting' | 'reconnected';
|
|
66
|
+
|
|
67
|
+
type Listener = (...args: any[]) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Togglerino SDK client.
|
|
70
|
+
*
|
|
71
|
+
* Usage:
|
|
72
|
+
* ```ts
|
|
73
|
+
* const client = new Togglerino({
|
|
74
|
+
* serverUrl: 'http://localhost:8080',
|
|
75
|
+
* sdkKey: 'sdk_abc123',
|
|
76
|
+
* context: { userId: 'user-42' },
|
|
77
|
+
* })
|
|
78
|
+
*
|
|
79
|
+
* await client.initialize()
|
|
80
|
+
*
|
|
81
|
+
* if (client.getBool('dark-mode')) {
|
|
82
|
+
* enableDarkMode()
|
|
83
|
+
* }
|
|
84
|
+
*
|
|
85
|
+
* client.on('change', (event) => {
|
|
86
|
+
* console.log('flag changed:', event.flagKey, event.value)
|
|
87
|
+
* })
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare class Togglerino {
|
|
91
|
+
private config;
|
|
92
|
+
private flags;
|
|
93
|
+
private listeners;
|
|
94
|
+
private pollTimer;
|
|
95
|
+
private sseAbortController;
|
|
96
|
+
private initialized;
|
|
97
|
+
private sseRetryCount;
|
|
98
|
+
private sseRetryTimeout;
|
|
99
|
+
private readonly maxRetryDelay;
|
|
100
|
+
constructor(config: TogglerinoConfig);
|
|
101
|
+
/**
|
|
102
|
+
* Initialize the client: fetch all flags and start listening for updates.
|
|
103
|
+
* Must be called before reading any flag values.
|
|
104
|
+
*/
|
|
105
|
+
initialize(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Get a boolean flag value.
|
|
108
|
+
* Returns `defaultValue` if the flag is not found or is not a boolean.
|
|
109
|
+
*/
|
|
110
|
+
getBool(key: string, defaultValue?: boolean): boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Get a string flag value.
|
|
113
|
+
* Returns `defaultValue` if the flag is not found or is not a string.
|
|
114
|
+
*/
|
|
115
|
+
getString(key: string, defaultValue?: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Get a numeric flag value.
|
|
118
|
+
* Returns `defaultValue` if the flag is not found or is not a number.
|
|
119
|
+
*/
|
|
120
|
+
getNumber(key: string, defaultValue?: number): number;
|
|
121
|
+
/**
|
|
122
|
+
* Get a JSON flag value (object, array, etc.).
|
|
123
|
+
* Returns `defaultValue` if the flag is not found.
|
|
124
|
+
*/
|
|
125
|
+
getJson<T = unknown>(key: string, defaultValue?: T): T;
|
|
126
|
+
/**
|
|
127
|
+
* Get the raw EvaluationResult for a flag.
|
|
128
|
+
* Returns undefined if the flag is not found.
|
|
129
|
+
*/
|
|
130
|
+
getDetail(key: string): EvaluationResult | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Subscribe to SDK events.
|
|
133
|
+
* Returns an unsubscribe function.
|
|
134
|
+
*
|
|
135
|
+
* Events:
|
|
136
|
+
* - "ready": no payload, fired after initialize() completes.
|
|
137
|
+
* - "change": payload is FlagChangeEvent.
|
|
138
|
+
* - "context_change": payload is EvaluationContext, fired after updateContext() completes.
|
|
139
|
+
* - "error": payload is Error.
|
|
140
|
+
* - "reconnecting": payload is { attempt: number, delay: number }, fired when scheduling SSE reconnect.
|
|
141
|
+
* - "reconnected": no payload, fired when SSE successfully reconnects after a disconnection.
|
|
142
|
+
*/
|
|
143
|
+
on(event: EventType, listener: Listener): () => void;
|
|
144
|
+
/**
|
|
145
|
+
* Get the current evaluation context.
|
|
146
|
+
*/
|
|
147
|
+
getContext(): EvaluationContext;
|
|
148
|
+
/**
|
|
149
|
+
* Update the evaluation context and re-fetch all flags.
|
|
150
|
+
* Useful when the user logs in / changes attributes.
|
|
151
|
+
*/
|
|
152
|
+
updateContext(context: Partial<EvaluationContext>): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* Stop all background activity (SSE stream, polling) and remove listeners.
|
|
155
|
+
* Call this when the client is no longer needed.
|
|
156
|
+
*/
|
|
157
|
+
close(): void;
|
|
158
|
+
/**
|
|
159
|
+
* Fetch all flags from the server evaluation endpoint.
|
|
160
|
+
*/
|
|
161
|
+
private fetchFlags;
|
|
162
|
+
/**
|
|
163
|
+
* Calculate the next retry delay using exponential backoff.
|
|
164
|
+
* Sequence: 1s, 2s, 4s, 8s, 16s, 30s (capped).
|
|
165
|
+
*/
|
|
166
|
+
private getRetryDelay;
|
|
167
|
+
/**
|
|
168
|
+
* Schedule an SSE reconnection attempt with exponential backoff.
|
|
169
|
+
* Starts polling as a fallback while retrying.
|
|
170
|
+
*/
|
|
171
|
+
private scheduleSSEReconnect;
|
|
172
|
+
/**
|
|
173
|
+
* Start an SSE connection using fetch + ReadableStream.
|
|
174
|
+
* This allows us to send an Authorization header (unlike native EventSource).
|
|
175
|
+
* On failure, schedules a reconnection attempt with exponential backoff
|
|
176
|
+
* and uses polling as a fallback in the meantime.
|
|
177
|
+
*/
|
|
178
|
+
private startSSE;
|
|
179
|
+
/**
|
|
180
|
+
* Read and parse SSE events from a ReadableStream.
|
|
181
|
+
* SSE format:
|
|
182
|
+
* event: flag_update
|
|
183
|
+
* data: {"flagKey":"dark-mode","value":true,"variant":"on"}
|
|
184
|
+
*
|
|
185
|
+
*/
|
|
186
|
+
private processSSEStream;
|
|
187
|
+
/**
|
|
188
|
+
* Parse a single SSE event block and update flags accordingly.
|
|
189
|
+
*/
|
|
190
|
+
private handleSSEEvent;
|
|
191
|
+
/**
|
|
192
|
+
* Start periodic polling as a fallback when SSE is unavailable.
|
|
193
|
+
*/
|
|
194
|
+
private startPolling;
|
|
195
|
+
private emit;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { type EvaluationContext, type EvaluationResult, type EventType, type FlagChangeEvent, type FlagDeletedEvent, Togglerino, type TogglerinoConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
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/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Togglerino: () => Togglerino
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/client.ts
|
|
28
|
+
var Togglerino = class {
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.flags = /* @__PURE__ */ new Map();
|
|
31
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
32
|
+
this.pollTimer = null;
|
|
33
|
+
this.sseAbortController = null;
|
|
34
|
+
this.initialized = false;
|
|
35
|
+
this.sseRetryCount = 0;
|
|
36
|
+
this.sseRetryTimeout = null;
|
|
37
|
+
this.maxRetryDelay = 3e4;
|
|
38
|
+
this.config = {
|
|
39
|
+
serverUrl: config.serverUrl.replace(/\/+$/, ""),
|
|
40
|
+
// strip trailing slash
|
|
41
|
+
sdkKey: config.sdkKey,
|
|
42
|
+
context: config.context ?? {},
|
|
43
|
+
streaming: config.streaming ?? true,
|
|
44
|
+
pollingInterval: config.pollingInterval ?? 3e4
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Initialize the client: fetch all flags and start listening for updates.
|
|
49
|
+
* Must be called before reading any flag values.
|
|
50
|
+
*/
|
|
51
|
+
async initialize() {
|
|
52
|
+
await this.fetchFlags();
|
|
53
|
+
this.initialized = true;
|
|
54
|
+
if (this.config.streaming) {
|
|
55
|
+
this.startSSE();
|
|
56
|
+
} else {
|
|
57
|
+
this.startPolling();
|
|
58
|
+
}
|
|
59
|
+
this.emit("ready", void 0);
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Typed flag getters (synchronous, read from local cache)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Get a boolean flag value.
|
|
66
|
+
* Returns `defaultValue` if the flag is not found or is not a boolean.
|
|
67
|
+
*/
|
|
68
|
+
getBool(key, defaultValue = false) {
|
|
69
|
+
const result = this.flags.get(key);
|
|
70
|
+
if (result === void 0 || typeof result.value !== "boolean") {
|
|
71
|
+
return defaultValue;
|
|
72
|
+
}
|
|
73
|
+
return result.value;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get a string flag value.
|
|
77
|
+
* Returns `defaultValue` if the flag is not found or is not a string.
|
|
78
|
+
*/
|
|
79
|
+
getString(key, defaultValue = "") {
|
|
80
|
+
const result = this.flags.get(key);
|
|
81
|
+
if (result === void 0 || typeof result.value !== "string") {
|
|
82
|
+
return defaultValue;
|
|
83
|
+
}
|
|
84
|
+
return result.value;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get a numeric flag value.
|
|
88
|
+
* Returns `defaultValue` if the flag is not found or is not a number.
|
|
89
|
+
*/
|
|
90
|
+
getNumber(key, defaultValue = 0) {
|
|
91
|
+
const result = this.flags.get(key);
|
|
92
|
+
if (result === void 0 || typeof result.value !== "number") {
|
|
93
|
+
return defaultValue;
|
|
94
|
+
}
|
|
95
|
+
return result.value;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get a JSON flag value (object, array, etc.).
|
|
99
|
+
* Returns `defaultValue` if the flag is not found.
|
|
100
|
+
*/
|
|
101
|
+
getJson(key, defaultValue) {
|
|
102
|
+
const result = this.flags.get(key);
|
|
103
|
+
if (result === void 0) {
|
|
104
|
+
return defaultValue;
|
|
105
|
+
}
|
|
106
|
+
return result.value;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get the raw EvaluationResult for a flag.
|
|
110
|
+
* Returns undefined if the flag is not found.
|
|
111
|
+
*/
|
|
112
|
+
getDetail(key) {
|
|
113
|
+
return this.flags.get(key);
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Event emitter
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/**
|
|
119
|
+
* Subscribe to SDK events.
|
|
120
|
+
* Returns an unsubscribe function.
|
|
121
|
+
*
|
|
122
|
+
* Events:
|
|
123
|
+
* - "ready": no payload, fired after initialize() completes.
|
|
124
|
+
* - "change": payload is FlagChangeEvent.
|
|
125
|
+
* - "context_change": payload is EvaluationContext, fired after updateContext() completes.
|
|
126
|
+
* - "error": payload is Error.
|
|
127
|
+
* - "reconnecting": payload is { attempt: number, delay: number }, fired when scheduling SSE reconnect.
|
|
128
|
+
* - "reconnected": no payload, fired when SSE successfully reconnects after a disconnection.
|
|
129
|
+
*/
|
|
130
|
+
on(event, listener) {
|
|
131
|
+
if (!this.listeners.has(event)) {
|
|
132
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
133
|
+
}
|
|
134
|
+
this.listeners.get(event).add(listener);
|
|
135
|
+
return () => {
|
|
136
|
+
this.listeners.get(event)?.delete(listener);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Context management
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
/**
|
|
143
|
+
* Get the current evaluation context.
|
|
144
|
+
*/
|
|
145
|
+
getContext() {
|
|
146
|
+
return {
|
|
147
|
+
...this.config.context,
|
|
148
|
+
attributes: this.config.context.attributes ? { ...this.config.context.attributes } : void 0
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Update the evaluation context and re-fetch all flags.
|
|
153
|
+
* Useful when the user logs in / changes attributes.
|
|
154
|
+
*/
|
|
155
|
+
async updateContext(context) {
|
|
156
|
+
this.config.context = {
|
|
157
|
+
...this.config.context,
|
|
158
|
+
...context
|
|
159
|
+
};
|
|
160
|
+
await this.fetchFlags();
|
|
161
|
+
this.emit("context_change", this.getContext());
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Cleanup
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Stop all background activity (SSE stream, polling) and remove listeners.
|
|
168
|
+
* Call this when the client is no longer needed.
|
|
169
|
+
*/
|
|
170
|
+
close() {
|
|
171
|
+
if (this.pollTimer !== null) {
|
|
172
|
+
clearInterval(this.pollTimer);
|
|
173
|
+
this.pollTimer = null;
|
|
174
|
+
}
|
|
175
|
+
if (this.sseAbortController) {
|
|
176
|
+
this.sseAbortController.abort();
|
|
177
|
+
this.sseAbortController = null;
|
|
178
|
+
}
|
|
179
|
+
if (this.sseRetryTimeout) {
|
|
180
|
+
clearTimeout(this.sseRetryTimeout);
|
|
181
|
+
this.sseRetryTimeout = null;
|
|
182
|
+
}
|
|
183
|
+
this.sseRetryCount = 0;
|
|
184
|
+
this.listeners.clear();
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Internal: flag fetching
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
/**
|
|
190
|
+
* Fetch all flags from the server evaluation endpoint.
|
|
191
|
+
*/
|
|
192
|
+
async fetchFlags() {
|
|
193
|
+
const url = `${this.config.serverUrl}/api/v1/evaluate`;
|
|
194
|
+
const body = {
|
|
195
|
+
context: {
|
|
196
|
+
user_id: this.config.context.userId ?? "",
|
|
197
|
+
attributes: this.config.context.attributes ?? {}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetch(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: {
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
Authorization: `Bearer ${this.config.sdkKey}`
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body)
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Togglerino: flag evaluation failed with status ${response.status}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const data = await response.json();
|
|
215
|
+
const oldFlags = new Map(this.flags);
|
|
216
|
+
this.flags.clear();
|
|
217
|
+
for (const [key, result] of Object.entries(data.flags)) {
|
|
218
|
+
this.flags.set(key, result);
|
|
219
|
+
if (this.initialized) {
|
|
220
|
+
const old = oldFlags.get(key);
|
|
221
|
+
if (!old || JSON.stringify(old.value) !== JSON.stringify(result.value)) {
|
|
222
|
+
this.emit("change", {
|
|
223
|
+
flagKey: key,
|
|
224
|
+
value: result.value,
|
|
225
|
+
variant: result.variant
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.emit("error", error);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Internal: SSE streaming (fetch-based with ReadableStream)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/**
|
|
239
|
+
* Calculate the next retry delay using exponential backoff.
|
|
240
|
+
* Sequence: 1s, 2s, 4s, 8s, 16s, 30s (capped).
|
|
241
|
+
*/
|
|
242
|
+
getRetryDelay() {
|
|
243
|
+
const delay = Math.min(1e3 * Math.pow(2, this.sseRetryCount), this.maxRetryDelay);
|
|
244
|
+
this.sseRetryCount++;
|
|
245
|
+
return delay;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Schedule an SSE reconnection attempt with exponential backoff.
|
|
249
|
+
* Starts polling as a fallback while retrying.
|
|
250
|
+
*/
|
|
251
|
+
scheduleSSEReconnect() {
|
|
252
|
+
if (this.pollTimer === null) {
|
|
253
|
+
this.startPolling();
|
|
254
|
+
}
|
|
255
|
+
const delay = this.getRetryDelay();
|
|
256
|
+
this.emit("reconnecting", { attempt: this.sseRetryCount, delay });
|
|
257
|
+
this.sseRetryTimeout = setTimeout(() => {
|
|
258
|
+
this.sseRetryTimeout = null;
|
|
259
|
+
this.startSSE();
|
|
260
|
+
}, delay);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Start an SSE connection using fetch + ReadableStream.
|
|
264
|
+
* This allows us to send an Authorization header (unlike native EventSource).
|
|
265
|
+
* On failure, schedules a reconnection attempt with exponential backoff
|
|
266
|
+
* and uses polling as a fallback in the meantime.
|
|
267
|
+
*/
|
|
268
|
+
async startSSE() {
|
|
269
|
+
const url = `${this.config.serverUrl}/api/v1/stream`;
|
|
270
|
+
this.sseAbortController = new AbortController();
|
|
271
|
+
const wasReconnecting = this.sseRetryCount > 0;
|
|
272
|
+
try {
|
|
273
|
+
const response = await fetch(url, {
|
|
274
|
+
headers: {
|
|
275
|
+
Authorization: `Bearer ${this.config.sdkKey}`,
|
|
276
|
+
Accept: "text/event-stream"
|
|
277
|
+
},
|
|
278
|
+
signal: this.sseAbortController.signal
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok || !response.body) {
|
|
281
|
+
this.scheduleSSEReconnect();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (wasReconnecting) {
|
|
285
|
+
this.emit("reconnected", void 0);
|
|
286
|
+
}
|
|
287
|
+
this.sseRetryCount = 0;
|
|
288
|
+
if (this.pollTimer !== null) {
|
|
289
|
+
clearInterval(this.pollTimer);
|
|
290
|
+
this.pollTimer = null;
|
|
291
|
+
}
|
|
292
|
+
const reader = response.body.getReader();
|
|
293
|
+
const decoder = new TextDecoder();
|
|
294
|
+
this.processSSEStream(reader, decoder).then(
|
|
295
|
+
() => {
|
|
296
|
+
this.scheduleSSEReconnect();
|
|
297
|
+
},
|
|
298
|
+
() => {
|
|
299
|
+
this.scheduleSSEReconnect();
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
} catch {
|
|
303
|
+
this.scheduleSSEReconnect();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Read and parse SSE events from a ReadableStream.
|
|
308
|
+
* SSE format:
|
|
309
|
+
* event: flag_update
|
|
310
|
+
* data: {"flagKey":"dark-mode","value":true,"variant":"on"}
|
|
311
|
+
*
|
|
312
|
+
*/
|
|
313
|
+
async processSSEStream(reader, decoder) {
|
|
314
|
+
let buffer = "";
|
|
315
|
+
while (true) {
|
|
316
|
+
const { done, value } = await reader.read();
|
|
317
|
+
if (done) break;
|
|
318
|
+
buffer += decoder.decode(value, { stream: true });
|
|
319
|
+
const parts = buffer.split("\n\n");
|
|
320
|
+
buffer = parts.pop() ?? "";
|
|
321
|
+
for (const part of parts) {
|
|
322
|
+
this.handleSSEEvent(part);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Parse a single SSE event block and update flags accordingly.
|
|
328
|
+
*/
|
|
329
|
+
handleSSEEvent(raw) {
|
|
330
|
+
let eventType = "";
|
|
331
|
+
let data = "";
|
|
332
|
+
for (const line of raw.split("\n")) {
|
|
333
|
+
if (line.startsWith("event:")) {
|
|
334
|
+
eventType = line.slice("event:".length).trim();
|
|
335
|
+
} else if (line.startsWith("data:")) {
|
|
336
|
+
data = line.slice("data:".length).trim();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (!data) return;
|
|
340
|
+
if (eventType === "flag_deleted") {
|
|
341
|
+
try {
|
|
342
|
+
const event = JSON.parse(data);
|
|
343
|
+
this.flags.delete(event.flagKey);
|
|
344
|
+
this.emit("deleted", event);
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (eventType !== "flag_update") return;
|
|
350
|
+
try {
|
|
351
|
+
const event = JSON.parse(data);
|
|
352
|
+
const existing = this.flags.get(event.flagKey);
|
|
353
|
+
this.flags.set(event.flagKey, {
|
|
354
|
+
value: event.value,
|
|
355
|
+
variant: event.variant,
|
|
356
|
+
reason: existing?.reason ?? "stream_update"
|
|
357
|
+
});
|
|
358
|
+
this.emit("change", event);
|
|
359
|
+
} catch {
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Internal: polling fallback
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
/**
|
|
366
|
+
* Start periodic polling as a fallback when SSE is unavailable.
|
|
367
|
+
*/
|
|
368
|
+
startPolling() {
|
|
369
|
+
if (this.pollTimer !== null) return;
|
|
370
|
+
this.pollTimer = setInterval(() => {
|
|
371
|
+
this.fetchFlags().catch(() => {
|
|
372
|
+
});
|
|
373
|
+
}, this.config.pollingInterval);
|
|
374
|
+
}
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Internal: event emission
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
emit(event, payload) {
|
|
379
|
+
const set = this.listeners.get(event);
|
|
380
|
+
if (!set) return;
|
|
381
|
+
for (const listener of set) {
|
|
382
|
+
try {
|
|
383
|
+
listener(payload);
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
390
|
+
0 && (module.exports = {
|
|
391
|
+
Togglerino
|
|
392
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
var Togglerino = class {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.flags = /* @__PURE__ */ new Map();
|
|
5
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
6
|
+
this.pollTimer = null;
|
|
7
|
+
this.sseAbortController = null;
|
|
8
|
+
this.initialized = false;
|
|
9
|
+
this.sseRetryCount = 0;
|
|
10
|
+
this.sseRetryTimeout = null;
|
|
11
|
+
this.maxRetryDelay = 3e4;
|
|
12
|
+
this.config = {
|
|
13
|
+
serverUrl: config.serverUrl.replace(/\/+$/, ""),
|
|
14
|
+
// strip trailing slash
|
|
15
|
+
sdkKey: config.sdkKey,
|
|
16
|
+
context: config.context ?? {},
|
|
17
|
+
streaming: config.streaming ?? true,
|
|
18
|
+
pollingInterval: config.pollingInterval ?? 3e4
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the client: fetch all flags and start listening for updates.
|
|
23
|
+
* Must be called before reading any flag values.
|
|
24
|
+
*/
|
|
25
|
+
async initialize() {
|
|
26
|
+
await this.fetchFlags();
|
|
27
|
+
this.initialized = true;
|
|
28
|
+
if (this.config.streaming) {
|
|
29
|
+
this.startSSE();
|
|
30
|
+
} else {
|
|
31
|
+
this.startPolling();
|
|
32
|
+
}
|
|
33
|
+
this.emit("ready", void 0);
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Typed flag getters (synchronous, read from local cache)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Get a boolean flag value.
|
|
40
|
+
* Returns `defaultValue` if the flag is not found or is not a boolean.
|
|
41
|
+
*/
|
|
42
|
+
getBool(key, defaultValue = false) {
|
|
43
|
+
const result = this.flags.get(key);
|
|
44
|
+
if (result === void 0 || typeof result.value !== "boolean") {
|
|
45
|
+
return defaultValue;
|
|
46
|
+
}
|
|
47
|
+
return result.value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get a string flag value.
|
|
51
|
+
* Returns `defaultValue` if the flag is not found or is not a string.
|
|
52
|
+
*/
|
|
53
|
+
getString(key, defaultValue = "") {
|
|
54
|
+
const result = this.flags.get(key);
|
|
55
|
+
if (result === void 0 || typeof result.value !== "string") {
|
|
56
|
+
return defaultValue;
|
|
57
|
+
}
|
|
58
|
+
return result.value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get a numeric flag value.
|
|
62
|
+
* Returns `defaultValue` if the flag is not found or is not a number.
|
|
63
|
+
*/
|
|
64
|
+
getNumber(key, defaultValue = 0) {
|
|
65
|
+
const result = this.flags.get(key);
|
|
66
|
+
if (result === void 0 || typeof result.value !== "number") {
|
|
67
|
+
return defaultValue;
|
|
68
|
+
}
|
|
69
|
+
return result.value;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get a JSON flag value (object, array, etc.).
|
|
73
|
+
* Returns `defaultValue` if the flag is not found.
|
|
74
|
+
*/
|
|
75
|
+
getJson(key, defaultValue) {
|
|
76
|
+
const result = this.flags.get(key);
|
|
77
|
+
if (result === void 0) {
|
|
78
|
+
return defaultValue;
|
|
79
|
+
}
|
|
80
|
+
return result.value;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get the raw EvaluationResult for a flag.
|
|
84
|
+
* Returns undefined if the flag is not found.
|
|
85
|
+
*/
|
|
86
|
+
getDetail(key) {
|
|
87
|
+
return this.flags.get(key);
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Event emitter
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to SDK events.
|
|
94
|
+
* Returns an unsubscribe function.
|
|
95
|
+
*
|
|
96
|
+
* Events:
|
|
97
|
+
* - "ready": no payload, fired after initialize() completes.
|
|
98
|
+
* - "change": payload is FlagChangeEvent.
|
|
99
|
+
* - "context_change": payload is EvaluationContext, fired after updateContext() completes.
|
|
100
|
+
* - "error": payload is Error.
|
|
101
|
+
* - "reconnecting": payload is { attempt: number, delay: number }, fired when scheduling SSE reconnect.
|
|
102
|
+
* - "reconnected": no payload, fired when SSE successfully reconnects after a disconnection.
|
|
103
|
+
*/
|
|
104
|
+
on(event, listener) {
|
|
105
|
+
if (!this.listeners.has(event)) {
|
|
106
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
107
|
+
}
|
|
108
|
+
this.listeners.get(event).add(listener);
|
|
109
|
+
return () => {
|
|
110
|
+
this.listeners.get(event)?.delete(listener);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Context management
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* Get the current evaluation context.
|
|
118
|
+
*/
|
|
119
|
+
getContext() {
|
|
120
|
+
return {
|
|
121
|
+
...this.config.context,
|
|
122
|
+
attributes: this.config.context.attributes ? { ...this.config.context.attributes } : void 0
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Update the evaluation context and re-fetch all flags.
|
|
127
|
+
* Useful when the user logs in / changes attributes.
|
|
128
|
+
*/
|
|
129
|
+
async updateContext(context) {
|
|
130
|
+
this.config.context = {
|
|
131
|
+
...this.config.context,
|
|
132
|
+
...context
|
|
133
|
+
};
|
|
134
|
+
await this.fetchFlags();
|
|
135
|
+
this.emit("context_change", this.getContext());
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Cleanup
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
/**
|
|
141
|
+
* Stop all background activity (SSE stream, polling) and remove listeners.
|
|
142
|
+
* Call this when the client is no longer needed.
|
|
143
|
+
*/
|
|
144
|
+
close() {
|
|
145
|
+
if (this.pollTimer !== null) {
|
|
146
|
+
clearInterval(this.pollTimer);
|
|
147
|
+
this.pollTimer = null;
|
|
148
|
+
}
|
|
149
|
+
if (this.sseAbortController) {
|
|
150
|
+
this.sseAbortController.abort();
|
|
151
|
+
this.sseAbortController = null;
|
|
152
|
+
}
|
|
153
|
+
if (this.sseRetryTimeout) {
|
|
154
|
+
clearTimeout(this.sseRetryTimeout);
|
|
155
|
+
this.sseRetryTimeout = null;
|
|
156
|
+
}
|
|
157
|
+
this.sseRetryCount = 0;
|
|
158
|
+
this.listeners.clear();
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Internal: flag fetching
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* Fetch all flags from the server evaluation endpoint.
|
|
165
|
+
*/
|
|
166
|
+
async fetchFlags() {
|
|
167
|
+
const url = `${this.config.serverUrl}/api/v1/evaluate`;
|
|
168
|
+
const body = {
|
|
169
|
+
context: {
|
|
170
|
+
user_id: this.config.context.userId ?? "",
|
|
171
|
+
attributes: this.config.context.attributes ?? {}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch(url, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
Authorization: `Bearer ${this.config.sdkKey}`
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(body)
|
|
182
|
+
});
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Togglerino: flag evaluation failed with status ${response.status}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const data = await response.json();
|
|
189
|
+
const oldFlags = new Map(this.flags);
|
|
190
|
+
this.flags.clear();
|
|
191
|
+
for (const [key, result] of Object.entries(data.flags)) {
|
|
192
|
+
this.flags.set(key, result);
|
|
193
|
+
if (this.initialized) {
|
|
194
|
+
const old = oldFlags.get(key);
|
|
195
|
+
if (!old || JSON.stringify(old.value) !== JSON.stringify(result.value)) {
|
|
196
|
+
this.emit("change", {
|
|
197
|
+
flagKey: key,
|
|
198
|
+
value: result.value,
|
|
199
|
+
variant: result.variant
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.emit("error", error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Internal: SSE streaming (fetch-based with ReadableStream)
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Calculate the next retry delay using exponential backoff.
|
|
214
|
+
* Sequence: 1s, 2s, 4s, 8s, 16s, 30s (capped).
|
|
215
|
+
*/
|
|
216
|
+
getRetryDelay() {
|
|
217
|
+
const delay = Math.min(1e3 * Math.pow(2, this.sseRetryCount), this.maxRetryDelay);
|
|
218
|
+
this.sseRetryCount++;
|
|
219
|
+
return delay;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Schedule an SSE reconnection attempt with exponential backoff.
|
|
223
|
+
* Starts polling as a fallback while retrying.
|
|
224
|
+
*/
|
|
225
|
+
scheduleSSEReconnect() {
|
|
226
|
+
if (this.pollTimer === null) {
|
|
227
|
+
this.startPolling();
|
|
228
|
+
}
|
|
229
|
+
const delay = this.getRetryDelay();
|
|
230
|
+
this.emit("reconnecting", { attempt: this.sseRetryCount, delay });
|
|
231
|
+
this.sseRetryTimeout = setTimeout(() => {
|
|
232
|
+
this.sseRetryTimeout = null;
|
|
233
|
+
this.startSSE();
|
|
234
|
+
}, delay);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Start an SSE connection using fetch + ReadableStream.
|
|
238
|
+
* This allows us to send an Authorization header (unlike native EventSource).
|
|
239
|
+
* On failure, schedules a reconnection attempt with exponential backoff
|
|
240
|
+
* and uses polling as a fallback in the meantime.
|
|
241
|
+
*/
|
|
242
|
+
async startSSE() {
|
|
243
|
+
const url = `${this.config.serverUrl}/api/v1/stream`;
|
|
244
|
+
this.sseAbortController = new AbortController();
|
|
245
|
+
const wasReconnecting = this.sseRetryCount > 0;
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetch(url, {
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${this.config.sdkKey}`,
|
|
250
|
+
Accept: "text/event-stream"
|
|
251
|
+
},
|
|
252
|
+
signal: this.sseAbortController.signal
|
|
253
|
+
});
|
|
254
|
+
if (!response.ok || !response.body) {
|
|
255
|
+
this.scheduleSSEReconnect();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (wasReconnecting) {
|
|
259
|
+
this.emit("reconnected", void 0);
|
|
260
|
+
}
|
|
261
|
+
this.sseRetryCount = 0;
|
|
262
|
+
if (this.pollTimer !== null) {
|
|
263
|
+
clearInterval(this.pollTimer);
|
|
264
|
+
this.pollTimer = null;
|
|
265
|
+
}
|
|
266
|
+
const reader = response.body.getReader();
|
|
267
|
+
const decoder = new TextDecoder();
|
|
268
|
+
this.processSSEStream(reader, decoder).then(
|
|
269
|
+
() => {
|
|
270
|
+
this.scheduleSSEReconnect();
|
|
271
|
+
},
|
|
272
|
+
() => {
|
|
273
|
+
this.scheduleSSEReconnect();
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
this.scheduleSSEReconnect();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Read and parse SSE events from a ReadableStream.
|
|
282
|
+
* SSE format:
|
|
283
|
+
* event: flag_update
|
|
284
|
+
* data: {"flagKey":"dark-mode","value":true,"variant":"on"}
|
|
285
|
+
*
|
|
286
|
+
*/
|
|
287
|
+
async processSSEStream(reader, decoder) {
|
|
288
|
+
let buffer = "";
|
|
289
|
+
while (true) {
|
|
290
|
+
const { done, value } = await reader.read();
|
|
291
|
+
if (done) break;
|
|
292
|
+
buffer += decoder.decode(value, { stream: true });
|
|
293
|
+
const parts = buffer.split("\n\n");
|
|
294
|
+
buffer = parts.pop() ?? "";
|
|
295
|
+
for (const part of parts) {
|
|
296
|
+
this.handleSSEEvent(part);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Parse a single SSE event block and update flags accordingly.
|
|
302
|
+
*/
|
|
303
|
+
handleSSEEvent(raw) {
|
|
304
|
+
let eventType = "";
|
|
305
|
+
let data = "";
|
|
306
|
+
for (const line of raw.split("\n")) {
|
|
307
|
+
if (line.startsWith("event:")) {
|
|
308
|
+
eventType = line.slice("event:".length).trim();
|
|
309
|
+
} else if (line.startsWith("data:")) {
|
|
310
|
+
data = line.slice("data:".length).trim();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!data) return;
|
|
314
|
+
if (eventType === "flag_deleted") {
|
|
315
|
+
try {
|
|
316
|
+
const event = JSON.parse(data);
|
|
317
|
+
this.flags.delete(event.flagKey);
|
|
318
|
+
this.emit("deleted", event);
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (eventType !== "flag_update") return;
|
|
324
|
+
try {
|
|
325
|
+
const event = JSON.parse(data);
|
|
326
|
+
const existing = this.flags.get(event.flagKey);
|
|
327
|
+
this.flags.set(event.flagKey, {
|
|
328
|
+
value: event.value,
|
|
329
|
+
variant: event.variant,
|
|
330
|
+
reason: existing?.reason ?? "stream_update"
|
|
331
|
+
});
|
|
332
|
+
this.emit("change", event);
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Internal: polling fallback
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
/**
|
|
340
|
+
* Start periodic polling as a fallback when SSE is unavailable.
|
|
341
|
+
*/
|
|
342
|
+
startPolling() {
|
|
343
|
+
if (this.pollTimer !== null) return;
|
|
344
|
+
this.pollTimer = setInterval(() => {
|
|
345
|
+
this.fetchFlags().catch(() => {
|
|
346
|
+
});
|
|
347
|
+
}, this.config.pollingInterval);
|
|
348
|
+
}
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Internal: event emission
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
emit(event, payload) {
|
|
353
|
+
const set = this.listeners.get(event);
|
|
354
|
+
if (!set) return;
|
|
355
|
+
for (const listener of set) {
|
|
356
|
+
try {
|
|
357
|
+
listener(payload);
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
export {
|
|
364
|
+
Togglerino
|
|
365
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@togglerino/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JavaScript/TypeScript SDK for togglerino feature flags",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"feature-flags",
|
|
18
|
+
"togglerino",
|
|
19
|
+
"sdk"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.5.1",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.0.18"
|
|
26
|
+
}
|
|
27
|
+
}
|