@cloudflare/flagship 0.0.0 → 0.0.1
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 +184 -0
- package/README.md +5 -0
- package/dist/index-C_sW3e_7.d.mts +233 -0
- package/dist/index-D8YLMfBG.d.cts +233 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/server.cjs +298 -0
- package/dist/server.d.cts +144 -0
- package/dist/server.d.mts +144 -0
- package/dist/server.mjs +290 -0
- package/dist/src-CiVDWmng.mjs +202 -0
- package/dist/src-De-abNIr.cjs +231 -0
- package/dist/web.cjs +148 -0
- package/dist/web.d.cts +72 -0
- package/dist/web.d.mts +72 -0
- package/dist/web.mjs +142 -0
- package/package.json +102 -10
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { a as FlagshipErrorCode, i as FlagshipError, n as ContextTransformer, r as FLAGSHIP_DEFAULT_BASE_URL, t as FlagshipClient } from "./src-CiVDWmng.mjs";
|
|
2
|
+
import { ErrorCode, OpenFeatureEventEmitter, ProviderEvents, ProviderStatus } from "@openfeature/server-sdk";
|
|
3
|
+
//#region src/server-provider.ts
|
|
4
|
+
const _noop = () => {};
|
|
5
|
+
/**
|
|
6
|
+
* OpenFeature provider for Flagship (server-side / dynamic context).
|
|
7
|
+
*
|
|
8
|
+
* Use this provider with `@openfeature/server-sdk` for Node.js,
|
|
9
|
+
* Cloudflare Workers, and other server-side JavaScript environments.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { OpenFeature } from '@openfeature/server-sdk';
|
|
14
|
+
* import { FlagshipServerProvider } from '@cloudflare/flagship/server';
|
|
15
|
+
*
|
|
16
|
+
* await OpenFeature.setProviderAndWait(
|
|
17
|
+
* new FlagshipServerProvider({
|
|
18
|
+
* appId: 'app-abc123',
|
|
19
|
+
* accountId: 'your-account-id',
|
|
20
|
+
* authToken: 'your-token',
|
|
21
|
+
* })
|
|
22
|
+
* );
|
|
23
|
+
*
|
|
24
|
+
* const client = OpenFeature.getClient();
|
|
25
|
+
* const value = await client.getBooleanValue('my-flag', false, {
|
|
26
|
+
* targetingKey: 'user-123',
|
|
27
|
+
* email: 'user@example.com',
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
var FlagshipServerProvider = class {
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.runsOn = "server";
|
|
34
|
+
this.events = new OpenFeatureEventEmitter();
|
|
35
|
+
this.currentStatus = ProviderStatus.NOT_READY;
|
|
36
|
+
this.metadata = { name: "Flagship Server Provider" };
|
|
37
|
+
this.client = new FlagshipClient(options);
|
|
38
|
+
this.logging = options.logging ?? false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns the provided logger when logging is enabled, or a no-op logger
|
|
42
|
+
* when `logging` is `false`. Using this in every resolve method ensures
|
|
43
|
+
* the SDK produces no console output unless the caller opts in.
|
|
44
|
+
*/
|
|
45
|
+
logger(logger) {
|
|
46
|
+
if (this.logging) return logger;
|
|
47
|
+
return {
|
|
48
|
+
debug: _noop,
|
|
49
|
+
info: _noop,
|
|
50
|
+
warn: _noop,
|
|
51
|
+
error: _noop
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Probes the evaluation endpoint with a health-check request. A 404 response
|
|
56
|
+
* is treated as success — it means the endpoint is reachable but the
|
|
57
|
+
* health-check flag simply doesn't exist, which is expected. Any network
|
|
58
|
+
* failure or timeout sets the status to ERROR.
|
|
59
|
+
*/
|
|
60
|
+
async initialize(_context) {
|
|
61
|
+
try {
|
|
62
|
+
await this.client.evaluate("_flagship_health_check", {});
|
|
63
|
+
this.currentStatus = ProviderStatus.READY;
|
|
64
|
+
this.events.emit(ProviderEvents.Ready);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof FlagshipError && error.cause instanceof Response && error.cause.status === 404) {
|
|
67
|
+
this.currentStatus = ProviderStatus.READY;
|
|
68
|
+
this.events.emit(ProviderEvents.Ready);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.currentStatus = ProviderStatus.ERROR;
|
|
72
|
+
this.events.emit(ProviderEvents.Error, { message: error instanceof Error ? error.message : String(error) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async onClose() {
|
|
76
|
+
this.currentStatus = ProviderStatus.NOT_READY;
|
|
77
|
+
}
|
|
78
|
+
get status() {
|
|
79
|
+
return this.currentStatus;
|
|
80
|
+
}
|
|
81
|
+
async resolveBooleanEvaluation(flagKey, defaultValue, context, logger) {
|
|
82
|
+
return this.resolve(flagKey, defaultValue, context, "boolean", logger);
|
|
83
|
+
}
|
|
84
|
+
async resolveStringEvaluation(flagKey, defaultValue, context, logger) {
|
|
85
|
+
return this.resolve(flagKey, defaultValue, context, "string", logger);
|
|
86
|
+
}
|
|
87
|
+
async resolveNumberEvaluation(flagKey, defaultValue, context, logger) {
|
|
88
|
+
return this.resolve(flagKey, defaultValue, context, "number", logger);
|
|
89
|
+
}
|
|
90
|
+
async resolveObjectEvaluation(flagKey, defaultValue, context, logger) {
|
|
91
|
+
return this.resolve(flagKey, defaultValue, context, "object", logger);
|
|
92
|
+
}
|
|
93
|
+
async resolve(flagKey, defaultValue, context, expectedType, logger) {
|
|
94
|
+
const log = this.logger(logger);
|
|
95
|
+
try {
|
|
96
|
+
log.debug(`[Flagship] Evaluating flag "${flagKey}" (expected: ${expectedType})`);
|
|
97
|
+
const result = await this.client.evaluate(flagKey, context);
|
|
98
|
+
const actualType = this.getValueType(result.value);
|
|
99
|
+
if (actualType !== expectedType) {
|
|
100
|
+
const msg = `Flag "${flagKey}" type mismatch: expected ${expectedType}, got ${actualType}`;
|
|
101
|
+
log.warn(`[Flagship] ${msg}`);
|
|
102
|
+
return {
|
|
103
|
+
value: defaultValue,
|
|
104
|
+
errorCode: ErrorCode.TYPE_MISMATCH,
|
|
105
|
+
errorMessage: msg,
|
|
106
|
+
reason: "ERROR"
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
log.debug(`[Flagship] Flag "${flagKey}" resolved: value=${String(result.value)} reason=${result.reason} variant=${result.variant}`);
|
|
110
|
+
return {
|
|
111
|
+
value: result.value,
|
|
112
|
+
variant: result.variant,
|
|
113
|
+
reason: result.reason,
|
|
114
|
+
flagMetadata: {}
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return this.handleError(flagKey, defaultValue, error, log);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Maps a runtime value to one of the four OpenFeature flag types.
|
|
122
|
+
* `null` maps to `'object'` (typeof null === 'object'), treating it as a
|
|
123
|
+
* JSON null which belongs to the object/structure category.
|
|
124
|
+
*/
|
|
125
|
+
getValueType(value) {
|
|
126
|
+
if (typeof value === "boolean") return "boolean";
|
|
127
|
+
if (typeof value === "string") return "string";
|
|
128
|
+
if (typeof value === "number") return "number";
|
|
129
|
+
return "object";
|
|
130
|
+
}
|
|
131
|
+
handleError(flagKey, defaultValue, error, logger) {
|
|
132
|
+
if (error instanceof FlagshipError) {
|
|
133
|
+
let errorCode;
|
|
134
|
+
switch (error.code) {
|
|
135
|
+
case FlagshipErrorCode.NETWORK_ERROR:
|
|
136
|
+
errorCode = error.cause instanceof Response && error.cause.status === 404 ? ErrorCode.FLAG_NOT_FOUND : ErrorCode.GENERAL;
|
|
137
|
+
break;
|
|
138
|
+
case FlagshipErrorCode.TIMEOUT_ERROR:
|
|
139
|
+
errorCode = ErrorCode.GENERAL;
|
|
140
|
+
break;
|
|
141
|
+
case FlagshipErrorCode.PARSE_ERROR:
|
|
142
|
+
errorCode = ErrorCode.PARSE_ERROR;
|
|
143
|
+
break;
|
|
144
|
+
case FlagshipErrorCode.INVALID_CONTEXT:
|
|
145
|
+
errorCode = ErrorCode.INVALID_CONTEXT;
|
|
146
|
+
break;
|
|
147
|
+
default: errorCode = ErrorCode.GENERAL;
|
|
148
|
+
}
|
|
149
|
+
logger.error(`[Flagship] Flag "${flagKey}" evaluation failed (${errorCode}): ${error.message}`);
|
|
150
|
+
return {
|
|
151
|
+
value: defaultValue,
|
|
152
|
+
errorCode,
|
|
153
|
+
errorMessage: error.message,
|
|
154
|
+
reason: "ERROR"
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const errorMessage = String(error);
|
|
158
|
+
logger.error(`[Flagship] Flag "${flagKey}" evaluation failed (GENERAL): ${errorMessage}`);
|
|
159
|
+
return {
|
|
160
|
+
value: defaultValue,
|
|
161
|
+
errorCode: ErrorCode.GENERAL,
|
|
162
|
+
errorMessage,
|
|
163
|
+
reason: "ERROR"
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/hooks/logging-hook.ts
|
|
169
|
+
/**
|
|
170
|
+
* Logging hook for debugging flag evaluations
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* import { OpenFeature } from '@openfeature/server-sdk';
|
|
175
|
+
* import { FlagshipServerProvider, LoggingHook } from '@cloudflare/flagship/server';
|
|
176
|
+
*
|
|
177
|
+
* const provider = new FlagshipServerProvider({ appId: 'your-app-id', accountId: 'your-account-id' });
|
|
178
|
+
* await OpenFeature.setProviderAndWait(provider);
|
|
179
|
+
*
|
|
180
|
+
* // Add logging hook
|
|
181
|
+
* OpenFeature.addHooks(new LoggingHook());
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
var LoggingHook = class {
|
|
185
|
+
constructor(logger = console.log) {
|
|
186
|
+
this.logger = logger;
|
|
187
|
+
}
|
|
188
|
+
before(hookContext, _hookHints) {
|
|
189
|
+
this.logger(`[Flagship] Evaluating flag: ${hookContext.flagKey}`, {
|
|
190
|
+
defaultValue: hookContext.defaultValue,
|
|
191
|
+
context: hookContext.context
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
after(hookContext, evaluationDetails, _hookHints) {
|
|
195
|
+
this.logger(`[Flagship] Flag ${hookContext.flagKey} evaluated`, {
|
|
196
|
+
value: evaluationDetails.value,
|
|
197
|
+
reason: evaluationDetails.reason,
|
|
198
|
+
variant: evaluationDetails.variant,
|
|
199
|
+
errorCode: evaluationDetails.errorCode
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
error(hookContext, error, _hookHints) {
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
this.logger(`[Flagship] Error evaluating flag ${hookContext.flagKey}:`, message);
|
|
205
|
+
}
|
|
206
|
+
finally(_hookContext, _evaluationDetails, _hookHints) {}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/hooks/telemetry-hook.ts
|
|
210
|
+
/**
|
|
211
|
+
* Telemetry hook for tracking flag evaluations
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```typescript
|
|
215
|
+
* import { OpenFeature } from '@openfeature/server-sdk';
|
|
216
|
+
* import { FlagshipServerProvider, TelemetryHook } from '@cloudflare/flagship/server';
|
|
217
|
+
*
|
|
218
|
+
* const telemetryHook = new TelemetryHook((event) => {
|
|
219
|
+
* // Send to your analytics service
|
|
220
|
+
* analytics.track('flag_evaluated', event);
|
|
221
|
+
* });
|
|
222
|
+
*
|
|
223
|
+
* OpenFeature.addHooks(telemetryHook);
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
var TelemetryHook = class {
|
|
227
|
+
constructor(onEvent) {
|
|
228
|
+
this.startTimes = /* @__PURE__ */ new Map();
|
|
229
|
+
this.contextKeys = /* @__PURE__ */ new WeakMap();
|
|
230
|
+
this.hints = /* @__PURE__ */ new WeakMap();
|
|
231
|
+
this.onEvent = onEvent;
|
|
232
|
+
}
|
|
233
|
+
before(hookContext, hookHints) {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const key = `${hookContext.flagKey}-${now}-${Math.random()}`;
|
|
236
|
+
this.startTimes.set(key, now);
|
|
237
|
+
this.contextKeys.set(hookContext, key);
|
|
238
|
+
if (hookHints !== void 0) this.hints.set(hookContext, hookHints);
|
|
239
|
+
}
|
|
240
|
+
after(hookContext, evaluationDetails, _hookHints) {
|
|
241
|
+
const telemetryKey = this.contextKeys.get(hookContext);
|
|
242
|
+
const startTime = telemetryKey ? this.startTimes.get(telemetryKey) : void 0;
|
|
243
|
+
const duration = startTime !== void 0 ? Date.now() - startTime : void 0;
|
|
244
|
+
if (telemetryKey !== void 0) {
|
|
245
|
+
this.startTimes.delete(telemetryKey);
|
|
246
|
+
this.contextKeys.delete(hookContext);
|
|
247
|
+
}
|
|
248
|
+
this.onEvent({
|
|
249
|
+
type: "evaluation",
|
|
250
|
+
flagKey: hookContext.flagKey,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
duration,
|
|
253
|
+
value: evaluationDetails.value,
|
|
254
|
+
reason: evaluationDetails.reason,
|
|
255
|
+
variant: evaluationDetails.variant,
|
|
256
|
+
errorCode: evaluationDetails.errorCode,
|
|
257
|
+
context: hookContext.context,
|
|
258
|
+
hints: this.hints.get(hookContext)
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
error(hookContext, error, _hookHints) {
|
|
262
|
+
const telemetryKey = this.contextKeys.get(hookContext);
|
|
263
|
+
const startTime = telemetryKey ? this.startTimes.get(telemetryKey) : void 0;
|
|
264
|
+
const duration = startTime !== void 0 ? Date.now() - startTime : void 0;
|
|
265
|
+
if (telemetryKey !== void 0) {
|
|
266
|
+
this.startTimes.delete(telemetryKey);
|
|
267
|
+
this.contextKeys.delete(hookContext);
|
|
268
|
+
}
|
|
269
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
270
|
+
this.onEvent({
|
|
271
|
+
type: "error",
|
|
272
|
+
flagKey: hookContext.flagKey,
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
duration,
|
|
275
|
+
errorMessage,
|
|
276
|
+
context: hookContext.context,
|
|
277
|
+
hints: this.hints.get(hookContext)
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
finally(hookContext, _evaluationDetails, _hookHints) {
|
|
281
|
+
const telemetryKey = this.contextKeys.get(hookContext);
|
|
282
|
+
if (telemetryKey !== void 0) {
|
|
283
|
+
this.startTimes.delete(telemetryKey);
|
|
284
|
+
this.contextKeys.delete(hookContext);
|
|
285
|
+
}
|
|
286
|
+
this.hints.delete(hookContext);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
//#endregion
|
|
290
|
+
export { ContextTransformer, FLAGSHIP_DEFAULT_BASE_URL, FlagshipClient, FlagshipError, FlagshipErrorCode, FlagshipServerProvider, LoggingHook, TelemetryHook };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
/** Default base URL for the Flagship API. */
|
|
3
|
+
const FLAGSHIP_DEFAULT_BASE_URL = "https://api.cloudflare.com";
|
|
4
|
+
/**
|
|
5
|
+
* Internal error codes produced by `FlagshipClient`.
|
|
6
|
+
* These are mapped to OpenFeature `ErrorCode` values by the providers.
|
|
7
|
+
*/
|
|
8
|
+
let FlagshipErrorCode = /* @__PURE__ */ function(FlagshipErrorCode) {
|
|
9
|
+
/** HTTP or fetch-level failure (non-404/400 status, connection refused, etc.) */
|
|
10
|
+
FlagshipErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
11
|
+
/** The request was aborted because the configured timeout elapsed. */
|
|
12
|
+
FlagshipErrorCode["TIMEOUT_ERROR"] = "TIMEOUT_ERROR";
|
|
13
|
+
/** The response body was not a valid evaluation response. */
|
|
14
|
+
FlagshipErrorCode["PARSE_ERROR"] = "PARSE_ERROR";
|
|
15
|
+
/** The evaluation context contained complex values that cannot be serialized to query parameters. */
|
|
16
|
+
FlagshipErrorCode["INVALID_CONTEXT"] = "INVALID_CONTEXT";
|
|
17
|
+
return FlagshipErrorCode;
|
|
18
|
+
}({});
|
|
19
|
+
/**
|
|
20
|
+
* Error thrown by `FlagshipClient` for all abnormal conditions.
|
|
21
|
+
* Carries a `code` for programmatic handling and an optional `cause` which
|
|
22
|
+
* is the underlying `Response` object for HTTP errors, allowing callers to
|
|
23
|
+
* inspect the status code (e.g. to distinguish 404 → `FLAG_NOT_FOUND`).
|
|
24
|
+
*/
|
|
25
|
+
var FlagshipError = class extends Error {
|
|
26
|
+
constructor(message, code, cause) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.cause = cause;
|
|
30
|
+
this.name = "FlagshipError";
|
|
31
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/context.ts
|
|
36
|
+
/**
|
|
37
|
+
* Utility for transforming OpenFeature evaluation context
|
|
38
|
+
*/
|
|
39
|
+
var ContextTransformer = class {
|
|
40
|
+
/**
|
|
41
|
+
* Transform OpenFeature evaluation context to query parameters
|
|
42
|
+
* for the Flagship API.
|
|
43
|
+
*
|
|
44
|
+
* Primitive values (`string`, `number`, `boolean`) and `Date` objects are
|
|
45
|
+
* serialized directly. Nested objects and arrays cannot be expressed as query
|
|
46
|
+
* parameters and are skipped.
|
|
47
|
+
*
|
|
48
|
+
* When a `droppedKeys` collector array is provided, skipped key names are
|
|
49
|
+
* pushed into it and **no** console warning is emitted — the caller is
|
|
50
|
+
* expected to handle the situation (e.g. throw `INVALID_CONTEXT`).
|
|
51
|
+
* When no collector is provided, a `console.warn` is emitted for each
|
|
52
|
+
* skipped key so the issue is still surfaced in development.
|
|
53
|
+
*
|
|
54
|
+
* @param context - OpenFeature evaluation context
|
|
55
|
+
* @param droppedKeys - Optional collector array; skipped key names are pushed here
|
|
56
|
+
*/
|
|
57
|
+
static toQueryParams(context, droppedKeys) {
|
|
58
|
+
const params = {};
|
|
59
|
+
for (const [key, value] of Object.entries(context)) {
|
|
60
|
+
if (value === void 0 || value === null) continue;
|
|
61
|
+
if (value instanceof Date) {
|
|
62
|
+
params[key] = value.toISOString();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
66
|
+
params[key] = String(value);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === "object") {
|
|
70
|
+
if (droppedKeys) droppedKeys.push(key);
|
|
71
|
+
else console.warn(`[Flagship] Context key "${key}" is a complex object/array and cannot be serialized to a query parameter. This value will be ignored during flag evaluation.`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return params;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build URL with query parameters from context.
|
|
79
|
+
*
|
|
80
|
+
* @param baseUrl - The base evaluation endpoint URL
|
|
81
|
+
* @param flagKey - The flag key to evaluate
|
|
82
|
+
* @param context - OpenFeature evaluation context
|
|
83
|
+
* @param droppedKeys - Optional collector array; skipped context key names are pushed here
|
|
84
|
+
*/
|
|
85
|
+
static buildUrl(baseUrl, flagKey, context, droppedKeys) {
|
|
86
|
+
const url = new URL(baseUrl);
|
|
87
|
+
url.searchParams.set("flagKey", flagKey);
|
|
88
|
+
const params = this.toQueryParams(context, droppedKeys);
|
|
89
|
+
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
90
|
+
return url.toString();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/client.ts
|
|
95
|
+
var FlagshipClient = class {
|
|
96
|
+
constructor(options) {
|
|
97
|
+
this.options = {
|
|
98
|
+
endpoint: resolveEndpoint(options),
|
|
99
|
+
fetchOptions: buildFetchOptions(options),
|
|
100
|
+
timeout: options.timeout || 5e3,
|
|
101
|
+
retries: Math.min(options.retries !== void 0 ? options.retries : 1, 10),
|
|
102
|
+
retryDelay: Math.min(options.retryDelay !== void 0 ? options.retryDelay : 1e3, 3e4)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Evaluate a flag with the given context.
|
|
107
|
+
*
|
|
108
|
+
* Throws a `FlagshipError` with `FlagshipErrorCode.INVALID_CONTEXT` if the
|
|
109
|
+
* evaluation context contains complex values (objects or arrays) that cannot
|
|
110
|
+
* be serialized to query parameters.
|
|
111
|
+
*/
|
|
112
|
+
async evaluate(flagKey, context) {
|
|
113
|
+
const droppedKeys = [];
|
|
114
|
+
const url = ContextTransformer.buildUrl(this.options.endpoint, flagKey, context, droppedKeys);
|
|
115
|
+
if (droppedKeys.length > 0) throw new FlagshipError(`Evaluation context contains complex values that cannot be serialized for flag "${flagKey}". Unsupported keys: ${droppedKeys.join(", ")}. Use primitive values (string, number, boolean) or Date objects.`, FlagshipErrorCode.INVALID_CONTEXT);
|
|
116
|
+
return this.fetchWithRetry(url, this.options.retries);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Fetch with retry logic. Only retries on transient network/server errors —
|
|
120
|
+
* 404 and 400 responses are terminal and propagated immediately.
|
|
121
|
+
*/
|
|
122
|
+
async fetchWithRetry(url, retriesLeft) {
|
|
123
|
+
try {
|
|
124
|
+
return await this.fetchWithTimeout(url, this.options.timeout);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof FlagshipError && error.cause instanceof Response) {
|
|
127
|
+
const status = error.cause.status;
|
|
128
|
+
if (status === 404 || status === 400) throw error;
|
|
129
|
+
}
|
|
130
|
+
if (retriesLeft > 0) {
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, this.options.retryDelay));
|
|
132
|
+
return this.fetchWithRetry(url, retriesLeft - 1);
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Fetch with timeout using AbortController
|
|
139
|
+
*/
|
|
140
|
+
async fetchWithTimeout(url, timeout) {
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(url, {
|
|
145
|
+
...this.options.fetchOptions,
|
|
146
|
+
signal: controller.signal
|
|
147
|
+
});
|
|
148
|
+
clearTimeout(timeoutId);
|
|
149
|
+
if (!response.ok) throw new FlagshipError(`HTTP ${response.status}: ${response.statusText}`, FlagshipErrorCode.NETWORK_ERROR, response);
|
|
150
|
+
const data = await response.json();
|
|
151
|
+
if (!data || typeof data !== "object" || !("flagKey" in data) || !("value" in data)) throw new FlagshipError("Invalid response format from Flagship API", FlagshipErrorCode.PARSE_ERROR);
|
|
152
|
+
return data;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
clearTimeout(timeoutId);
|
|
155
|
+
if (error instanceof Error && error.name === "AbortError") throw new FlagshipError(`Request timeout after ${timeout}ms`, FlagshipErrorCode.TIMEOUT_ERROR, error);
|
|
156
|
+
if (error instanceof FlagshipError) throw error;
|
|
157
|
+
throw new FlagshipError(`Network error: ${error}`, FlagshipErrorCode.NETWORK_ERROR, error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Merge `authToken` and `fetchOptions` into a single `RequestInit`.
|
|
163
|
+
*
|
|
164
|
+
* Precedence for the `Authorization` header (highest → lowest):
|
|
165
|
+
* 1. An explicit `Authorization` value inside `fetchOptions.headers`
|
|
166
|
+
* 2. A value derived from `authToken`
|
|
167
|
+
*
|
|
168
|
+
* All other `fetchOptions` fields are spread as-is.
|
|
169
|
+
*/
|
|
170
|
+
function buildFetchOptions(options) {
|
|
171
|
+
const { authToken, fetchOptions = {} } = options;
|
|
172
|
+
if (!authToken) return fetchOptions;
|
|
173
|
+
const existingHeaders = new Headers(fetchOptions.headers);
|
|
174
|
+
if (!existingHeaders.has("Authorization")) existingHeaders.set("Authorization", `Bearer ${authToken}`);
|
|
175
|
+
return {
|
|
176
|
+
...fetchOptions,
|
|
177
|
+
headers: existingHeaders
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function resolveEndpoint(options) {
|
|
181
|
+
const { appId, endpoint, baseUrl, accountId } = options;
|
|
182
|
+
if (appId && endpoint) throw new Error("Flagship: provide either \"appId\" or \"endpoint\", not both");
|
|
183
|
+
if (!appId && !endpoint) throw new Error("Flagship: either \"appId\" or \"endpoint\" is required");
|
|
184
|
+
if (endpoint) {
|
|
185
|
+
try {
|
|
186
|
+
new URL(endpoint);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(`Flagship: invalid endpoint URL: ${endpoint}`);
|
|
189
|
+
}
|
|
190
|
+
return endpoint;
|
|
191
|
+
}
|
|
192
|
+
if (!accountId) throw new Error("Flagship: \"accountId\" is required when using \"appId\"");
|
|
193
|
+
const resolved = `${(baseUrl || "https://api.cloudflare.com").replace(/\/+$/, "")}/client/v4/accounts/${encodeURIComponent(accountId)}/flagship/apps/${encodeURIComponent(appId)}/evaluate`;
|
|
194
|
+
try {
|
|
195
|
+
new URL(resolved);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new Error(`Flagship: resolved endpoint is not a valid URL: ${resolved}`);
|
|
198
|
+
}
|
|
199
|
+
return resolved;
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
export { FlagshipErrorCode as a, FlagshipError as i, ContextTransformer as n, FLAGSHIP_DEFAULT_BASE_URL as r, FlagshipClient as t };
|