@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.
@@ -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 };