From f4a7f4f80df0b2a02a3ab8b2ce96c49e4b48988b Mon Sep 17 00:00:00 2001 From: Rohan Mukherjee Date: Mon, 30 Mar 2026 17:50:53 +0530 Subject: [PATCH 1/3] feat: add Flags binding for feature flag evaluation Add a new wrapped binding for feature flags, following the same pattern as the AI binding. Includes the public module (cloudflare:flags), internal implementation (cloudflare-internal:flags-api), type definitions, updated type snapshots, and tests with a mock service. --- src/cloudflare/flags.ts | 9 + src/cloudflare/internal/flags-api.ts | 178 ++++++++++++++++++ .../internal/test/flags/BUILD.bazel | 7 + .../internal/test/flags/flags-api-test.js | 81 ++++++++ .../test/flags/flags-api-test.wd-test | 34 ++++ .../internal/test/flags/flags-mock.js | 48 +++++ types/defines/flags.d.ts | 103 ++++++++++ .../experimental/index.d.ts | 93 +++++++++ .../generated-snapshot/experimental/index.ts | 93 +++++++++ types/generated-snapshot/latest/index.d.ts | 93 +++++++++ types/generated-snapshot/latest/index.ts | 93 +++++++++ 11 files changed, 832 insertions(+) create mode 100644 src/cloudflare/flags.ts create mode 100644 src/cloudflare/internal/flags-api.ts create mode 100644 src/cloudflare/internal/test/flags/BUILD.bazel create mode 100644 src/cloudflare/internal/test/flags/flags-api-test.js create mode 100644 src/cloudflare/internal/test/flags/flags-api-test.wd-test create mode 100644 src/cloudflare/internal/test/flags/flags-mock.js create mode 100644 types/defines/flags.d.ts diff --git a/src/cloudflare/flags.ts b/src/cloudflare/flags.ts new file mode 100644 index 00000000000..4054d5540c1 --- /dev/null +++ b/src/cloudflare/flags.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +export { + type EvaluationDetails, + FlagEvaluationError, + Flags, +} from 'cloudflare-internal:flags-api'; diff --git a/src/cloudflare/internal/flags-api.ts b/src/cloudflare/internal/flags-api.ts new file mode 100644 index 00000000000..534984d91d8 --- /dev/null +++ b/src/cloudflare/internal/flags-api.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +interface Fetcher { + fetch: typeof fetch; +} + +export interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} + +export class FlagEvaluationError extends Error { + constructor(message: string, name = 'FlagEvaluationError') { + super(message); + this.name = name; + } +} + +export class Flags { + #fetcher: Fetcher; + #endpointUrl = 'https://workers-binding.flags'; + + constructor(fetcher: Fetcher) { + this.#fetcher = fetcher; + } + + async get(flagKey: string, defaultValue?: unknown): Promise { + const details = await this.#evaluate(flagKey, defaultValue); + return details.value; + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean + ): Promise { + const details = await this.getBooleanDetails(flagKey, defaultValue); + return details.value; + } + + async getStringValue(flagKey: string, defaultValue: string): Promise { + const details = await this.getStringDetails(flagKey, defaultValue); + return details.value; + } + + async getNumberValue(flagKey: string, defaultValue: number): Promise { + const details = await this.getNumberDetails(flagKey, defaultValue); + return details.value; + } + + async getObjectValue( + flagKey: string, + defaultValue: T + ): Promise { + const details = await this.getObjectDetails(flagKey, defaultValue); + return details.value; + } + + async getBooleanDetails( + flagKey: string, + defaultValue: boolean + ): Promise> { + const details = await this.#evaluate(flagKey, defaultValue); + if (typeof details.value !== 'boolean') { + return { + ...details, + value: defaultValue, + errorCode: 'TYPE_MISMATCH', + errorMessage: `Expected boolean but got ${typeof details.value}`, + }; + } + return details as EvaluationDetails; + } + + async getStringDetails( + flagKey: string, + defaultValue: string + ): Promise> { + const details = await this.#evaluate(flagKey, defaultValue); + if (typeof details.value !== 'string') { + return { + ...details, + value: defaultValue, + errorCode: 'TYPE_MISMATCH', + errorMessage: `Expected string but got ${typeof details.value}`, + }; + } + return details as EvaluationDetails; + } + + async getNumberDetails( + flagKey: string, + defaultValue: number + ): Promise> { + const details = await this.#evaluate(flagKey, defaultValue); + if (typeof details.value !== 'number') { + return { + ...details, + value: defaultValue, + errorCode: 'TYPE_MISMATCH', + errorMessage: `Expected number but got ${typeof details.value}`, + }; + } + return details as EvaluationDetails; + } + + async getObjectDetails( + flagKey: string, + defaultValue: T + ): Promise> { + const details = await this.#evaluate(flagKey, defaultValue); + if (typeof details.value !== 'object' || details.value === null) { + return { + ...details, + value: defaultValue, + errorCode: 'TYPE_MISMATCH', + errorMessage: `Expected object but got ${typeof details.value}`, + }; + } + return details as EvaluationDetails; + } + + async #evaluate( + flagKey: string, + defaultValue: unknown + ): Promise> { + try { + const res = await this.#fetcher.fetch( + `${this.#endpointUrl}/flags/${encodeURIComponent(flagKey)}`, + { + method: 'GET', + headers: { + 'content-type': 'application/json', + }, + } + ); + + if (!res.ok) { + const text = await res.text(); + return { + flagKey, + value: defaultValue, + errorCode: 'GENERAL', + errorMessage: text, + }; + } + + const data = (await res.json()) as { + value: unknown; + variant?: string; + reason?: string; + }; + + return { + flagKey, + value: data.value, + variant: data.variant, + reason: data.reason, + }; + } catch (err) { + return { + flagKey, + value: defaultValue, + errorCode: 'GENERAL', + errorMessage: err instanceof Error ? err.message : String(err), + }; + } + } +} + +export default function makeBinding(env: { fetcher: Fetcher }): Flags { + return new Flags(env.fetcher); +} diff --git a/src/cloudflare/internal/test/flags/BUILD.bazel b/src/cloudflare/internal/test/flags/BUILD.bazel new file mode 100644 index 00000000000..9d0ede61585 --- /dev/null +++ b/src/cloudflare/internal/test/flags/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:build/wd_test.bzl", "wd_test") + +wd_test( + src = "flags-api-test.wd-test", + args = ["--experimental"], + data = glob(["*.js"]), +) diff --git a/src/cloudflare/internal/test/flags/flags-api-test.js b/src/cloudflare/internal/test/flags/flags-api-test.js new file mode 100644 index 00000000000..cd6b5a1656d --- /dev/null +++ b/src/cloudflare/internal/test/flags/flags-api-test.js @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from 'node:assert'; + +export const getBooleanValueTest = { + async test(_, env) { + const value = await env.FLAGS.getBooleanValue('bool-flag', false); + assert.strictEqual(value, true); + }, +}; + +export const getStringValueTest = { + async test(_, env) { + const value = await env.FLAGS.getStringValue('string-flag', 'default'); + assert.strictEqual(value, 'variant-a'); + }, +}; + +export const getNumberValueTest = { + async test(_, env) { + const value = await env.FLAGS.getNumberValue('number-flag', 0); + assert.strictEqual(value, 42); + }, +}; + +export const getObjectValueTest = { + async test(_, env) { + const value = await env.FLAGS.getObjectValue('object-flag', {}); + assert.deepStrictEqual(value, { color: 'blue', size: 10 }); + }, +}; + +export const getGenericTest = { + async test(_, env) { + const value = await env.FLAGS.get('bool-flag'); + assert.strictEqual(value, true); + }, +}; + +export const getBooleanDetailsTest = { + async test(_, env) { + const details = await env.FLAGS.getBooleanDetails('bool-flag', false); + assert.strictEqual(details.flagKey, 'bool-flag'); + assert.strictEqual(details.value, true); + assert.strictEqual(details.variant, 'on'); + assert.strictEqual(details.reason, 'TARGETING_MATCH'); + assert.strictEqual(details.errorCode, undefined); + }, +}; + +export const typeMismatchReturnsDefaultTest = { + async test(_, env) { + // type-mismatch-flag returns a string, but we ask for boolean + const details = await env.FLAGS.getBooleanDetails( + 'type-mismatch-flag', + false + ); + assert.strictEqual(details.value, false); // default value + assert.strictEqual(details.errorCode, 'TYPE_MISMATCH'); + }, +}; + +export const missingFlagReturnsDefaultTest = { + async test(_, env) { + const value = await env.FLAGS.getBooleanValue('nonexistent-flag', false); + assert.strictEqual(value, false); + }, +}; + +export const getDefaultValueOnErrorTest = { + async test(_, env) { + const details = await env.FLAGS.getStringDetails( + 'nonexistent-flag', + 'fallback' + ); + assert.strictEqual(details.value, 'fallback'); + assert.strictEqual(details.errorCode, 'GENERAL'); + }, +}; diff --git a/src/cloudflare/internal/test/flags/flags-api-test.wd-test b/src/cloudflare/internal/test/flags/flags-api-test.wd-test new file mode 100644 index 00000000000..4c9f78c60d8 --- /dev/null +++ b/src/cloudflare/internal/test/flags/flags-api-test.wd-test @@ -0,0 +1,34 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "flags-api-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "flags-api-test.js") + ], + compatibilityFlags = ["nodejs_compat"], + bindings = [ + ( + name = "FLAGS", + wrapped = ( + moduleName = "cloudflare-internal:flags-api", + innerBindings = [( + name = "fetcher", + service = "flags-mock" + )], + ) + ) + ], + ) + ), + ( name = "flags-mock", + worker = ( + compatibilityFlags = ["experimental", "nodejs_compat"], + modules = [ + (name = "worker", esModule = embed "flags-mock.js") + ], + ) + ) + ] +); diff --git a/src/cloudflare/internal/test/flags/flags-mock.js b/src/cloudflare/internal/test/flags/flags-mock.js new file mode 100644 index 00000000000..f4a50d4103c --- /dev/null +++ b/src/cloudflare/internal/test/flags/flags-mock.js @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Mock flag store for testing. Simulates a backend flags service. +const FLAGS = { + 'bool-flag': { value: true, variant: 'on', reason: 'TARGETING_MATCH' }, + 'string-flag': { + value: 'variant-a', + variant: 'a', + reason: 'TARGETING_MATCH', + }, + 'number-flag': { value: 42, variant: 'fourty-two', reason: 'DEFAULT' }, + 'object-flag': { + value: { color: 'blue', size: 10 }, + variant: 'blue-10', + reason: 'SPLIT', + }, + 'type-mismatch-flag': { + value: 'not-a-boolean', + variant: 'default', + reason: 'DEFAULT', + }, +}; + +export default { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + // Expected path: /flags/ + const match = url.pathname.match(/^\/flags\/(.+)$/); + if (!match) { + return new Response('Not found', { status: 404 }); + } + + const flagKey = decodeURIComponent(match[1]); + const flag = FLAGS[flagKey]; + + if (!flag) { + return Response.json( + { error: 'FLAG_NOT_FOUND', message: `Flag "${flagKey}" not found` }, + { status: 404 } + ); + } + + return Response.json(flag); + }, +}; diff --git a/types/defines/flags.d.ts b/types/defines/flags.d.ts new file mode 100644 index 00000000000..e7ae1ecca79 --- /dev/null +++ b/types/defines/flags.d.ts @@ -0,0 +1,103 @@ +export interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} + +export interface FlagEvaluationError extends Error {} + +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +export declare abstract class Flags { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + */ + get(flagKey: string, defaultValue?: unknown): Promise; + + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringValue(flagKey: string, defaultValue: string): Promise; + + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberValue(flagKey: string, defaultValue: number): Promise; + + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectValue( + flagKey: string, + defaultValue: T + ): Promise; + + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean + ): Promise>; + + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringDetails( + flagKey: string, + defaultValue: string + ): Promise>; + + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberDetails( + flagKey: string, + defaultValue: number + ): Promise>; + + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectDetails( + flagKey: string, + defaultValue: T + ): Promise>; +} diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 87d7dc1376f..3ce7040d050 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -12466,6 +12466,99 @@ declare module "cloudflare:email" { }; export { _EmailMessage as EmailMessage }; } +interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} +interface FlagEvaluationError extends Error {} +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +declare abstract class Flags { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + */ + get(flagKey: string, defaultValue?: unknown): Promise; + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringValue(flagKey: string, defaultValue: string): Promise; + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberValue(flagKey: string, defaultValue: number): Promise; + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectValue( + flagKey: string, + defaultValue: T, + ): Promise; + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + ): Promise>; + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringDetails( + flagKey: string, + defaultValue: string, + ): Promise>; + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + ): Promise>; + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectDetails( + flagKey: string, + defaultValue: T, + ): Promise>; +} /** * Hello World binding to serve as an explanatory example. DO NOT USE */ diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index d52a5c56a9b..4026f61ffb0 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -12482,6 +12482,99 @@ export declare type EmailExportedHandler = ( env: Env, ctx: ExecutionContext, ) => void | Promise; +export interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} +export interface FlagEvaluationError extends Error {} +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +export declare abstract class Flags { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + */ + get(flagKey: string, defaultValue?: unknown): Promise; + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringValue(flagKey: string, defaultValue: string): Promise; + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberValue(flagKey: string, defaultValue: number): Promise; + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectValue( + flagKey: string, + defaultValue: T, + ): Promise; + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + ): Promise>; + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringDetails( + flagKey: string, + defaultValue: string, + ): Promise>; + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + ): Promise>; + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectDetails( + flagKey: string, + defaultValue: T, + ): Promise>; +} /** * Hello World binding to serve as an explanatory example. DO NOT USE */ diff --git a/types/generated-snapshot/latest/index.d.ts b/types/generated-snapshot/latest/index.d.ts index 8e6b76d564d..6940f530f4a 100755 --- a/types/generated-snapshot/latest/index.d.ts +++ b/types/generated-snapshot/latest/index.d.ts @@ -11753,6 +11753,99 @@ declare module "cloudflare:email" { }; export { _EmailMessage as EmailMessage }; } +interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} +interface FlagEvaluationError extends Error {} +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +declare abstract class Flags { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + */ + get(flagKey: string, defaultValue?: unknown): Promise; + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringValue(flagKey: string, defaultValue: string): Promise; + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberValue(flagKey: string, defaultValue: number): Promise; + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectValue( + flagKey: string, + defaultValue: T, + ): Promise; + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + ): Promise>; + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringDetails( + flagKey: string, + defaultValue: string, + ): Promise>; + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + ): Promise>; + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectDetails( + flagKey: string, + defaultValue: T, + ): Promise>; +} /** * Hello World binding to serve as an explanatory example. DO NOT USE */ diff --git a/types/generated-snapshot/latest/index.ts b/types/generated-snapshot/latest/index.ts index f99255aea9e..e6284537d54 100755 --- a/types/generated-snapshot/latest/index.ts +++ b/types/generated-snapshot/latest/index.ts @@ -11769,6 +11769,99 @@ export declare type EmailExportedHandler = ( env: Env, ctx: ExecutionContext, ) => void | Promise; +export interface EvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} +export interface FlagEvaluationError extends Error {} +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +export declare abstract class Flags { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + */ + get(flagKey: string, defaultValue?: unknown): Promise; + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringValue(flagKey: string, defaultValue: string): Promise; + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberValue(flagKey: string, defaultValue: number): Promise; + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectValue( + flagKey: string, + defaultValue: T, + ): Promise; + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + ): Promise>; + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getStringDetails( + flagKey: string, + defaultValue: string, + ): Promise>; + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + ): Promise>; + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + */ + getObjectDetails( + flagKey: string, + defaultValue: T, + ): Promise>; +} /** * Hello World binding to serve as an explanatory example. DO NOT USE */ From f1121c0aa91b02fe0b934e33fe008e94c94d1be9 Mon Sep 17 00:00:00 2001 From: Rohan Mukherjee Date: Mon, 30 Mar 2026 19:09:25 +0530 Subject: [PATCH 2/3] refactor: convert Flags binding to JSRPC matching FlagshipBinding contract Switch from HTTP fetch to JSRPC, aligning with the real FlagshipBinding entrypoint (evaluate(flagKey, context?)). Add EvaluationContext parameter to all public methods for targeting rules. Update mock to extend WorkerEntrypoint, regenerate type snapshots. --- src/cloudflare/flags.ts | 1 + src/cloudflare/internal/flags-api.ts | 119 ++++++++++-------- .../internal/test/flags/flags-api-test.js | 21 ++++ .../test/flags/flags-api-test.wd-test | 9 +- .../internal/test/flags/flags-mock.js | 46 ++++--- types/defines/flags.d.ts | 60 +++++++-- .../experimental/index.d.ts | 49 +++++++- .../generated-snapshot/experimental/index.ts | 49 +++++++- types/generated-snapshot/latest/index.d.ts | 49 +++++++- types/generated-snapshot/latest/index.ts | 49 +++++++- 10 files changed, 351 insertions(+), 101 deletions(-) diff --git a/src/cloudflare/flags.ts b/src/cloudflare/flags.ts index 4054d5540c1..db0b4b78801 100644 --- a/src/cloudflare/flags.ts +++ b/src/cloudflare/flags.ts @@ -3,6 +3,7 @@ // https://opensource.org/licenses/Apache-2.0 export { + type EvaluationContext, type EvaluationDetails, FlagEvaluationError, Flags, diff --git a/src/cloudflare/internal/flags-api.ts b/src/cloudflare/internal/flags-api.ts index 534984d91d8..222d28caf0d 100644 --- a/src/cloudflare/internal/flags-api.ts +++ b/src/cloudflare/internal/flags-api.ts @@ -2,10 +2,23 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -interface Fetcher { - fetch: typeof fetch; +/** + * Stub for the FlagshipBinding JSRPC entrypoint. + * Matches the contract in control-plane/src/binding.ts. + */ +interface FlagshipBindingStub { + evaluate( + flagKey: string, + context?: EvaluationContext + ): Promise<{ + value: unknown; + variant?: string; + reason?: string; + }>; } +export type EvaluationContext = Record; + export interface EvaluationDetails { flagKey: string; value: T; @@ -23,49 +36,67 @@ export class FlagEvaluationError extends Error { } export class Flags { - #fetcher: Fetcher; - #endpointUrl = 'https://workers-binding.flags'; + #fetcher: FlagshipBindingStub; - constructor(fetcher: Fetcher) { + constructor(fetcher: FlagshipBindingStub) { this.#fetcher = fetcher; } - async get(flagKey: string, defaultValue?: unknown): Promise { - const details = await this.#evaluate(flagKey, defaultValue); + async get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext + ): Promise { + const details = await this.#evaluate(flagKey, defaultValue, context); return details.value; } async getBooleanValue( flagKey: string, - defaultValue: boolean + defaultValue: boolean, + context?: EvaluationContext ): Promise { - const details = await this.getBooleanDetails(flagKey, defaultValue); + const details = await this.getBooleanDetails( + flagKey, + defaultValue, + context + ); return details.value; } - async getStringValue(flagKey: string, defaultValue: string): Promise { - const details = await this.getStringDetails(flagKey, defaultValue); + async getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise { + const details = await this.getStringDetails(flagKey, defaultValue, context); return details.value; } - async getNumberValue(flagKey: string, defaultValue: number): Promise { - const details = await this.getNumberDetails(flagKey, defaultValue); + async getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise { + const details = await this.getNumberDetails(flagKey, defaultValue, context); return details.value; } async getObjectValue( flagKey: string, - defaultValue: T + defaultValue: T, + context?: EvaluationContext ): Promise { - const details = await this.getObjectDetails(flagKey, defaultValue); + const details = await this.getObjectDetails(flagKey, defaultValue, context); return details.value; } async getBooleanDetails( flagKey: string, - defaultValue: boolean + defaultValue: boolean, + context?: EvaluationContext ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue); + const details = await this.#evaluate(flagKey, defaultValue, context); if (typeof details.value !== 'boolean') { return { ...details, @@ -79,9 +110,10 @@ export class Flags { async getStringDetails( flagKey: string, - defaultValue: string + defaultValue: string, + context?: EvaluationContext ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue); + const details = await this.#evaluate(flagKey, defaultValue, context); if (typeof details.value !== 'string') { return { ...details, @@ -95,9 +127,10 @@ export class Flags { async getNumberDetails( flagKey: string, - defaultValue: number + defaultValue: number, + context?: EvaluationContext ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue); + const details = await this.#evaluate(flagKey, defaultValue, context); if (typeof details.value !== 'number') { return { ...details, @@ -111,9 +144,10 @@ export class Flags { async getObjectDetails( flagKey: string, - defaultValue: T + defaultValue: T, + context?: EvaluationContext ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue); + const details = await this.#evaluate(flagKey, defaultValue, context); if (typeof details.value !== 'object' || details.value === null) { return { ...details, @@ -127,40 +161,17 @@ export class Flags { async #evaluate( flagKey: string, - defaultValue: unknown + defaultValue: unknown, + context?: EvaluationContext ): Promise> { try { - const res = await this.#fetcher.fetch( - `${this.#endpointUrl}/flags/${encodeURIComponent(flagKey)}`, - { - method: 'GET', - headers: { - 'content-type': 'application/json', - }, - } - ); - - if (!res.ok) { - const text = await res.text(); - return { - flagKey, - value: defaultValue, - errorCode: 'GENERAL', - errorMessage: text, - }; - } - - const data = (await res.json()) as { - value: unknown; - variant?: string; - reason?: string; - }; + const result = await this.#fetcher.evaluate(flagKey, context); return { flagKey, - value: data.value, - variant: data.variant, - reason: data.reason, + value: result.value, + variant: result.variant, + reason: result.reason, }; } catch (err) { return { @@ -173,6 +184,8 @@ export class Flags { } } -export default function makeBinding(env: { fetcher: Fetcher }): Flags { +export default function makeBinding(env: { + fetcher: FlagshipBindingStub; +}): Flags { return new Flags(env.fetcher); } diff --git a/src/cloudflare/internal/test/flags/flags-api-test.js b/src/cloudflare/internal/test/flags/flags-api-test.js index cd6b5a1656d..73ba750b4e5 100644 --- a/src/cloudflare/internal/test/flags/flags-api-test.js +++ b/src/cloudflare/internal/test/flags/flags-api-test.js @@ -79,3 +79,24 @@ export const getDefaultValueOnErrorTest = { assert.strictEqual(details.errorCode, 'GENERAL'); }, }; + +export const evaluationContextTest = { + async test(_, env) { + // Without context, should get the default value + const defaultValue = await env.FLAGS.getStringValue( + 'context-flag', + 'fallback' + ); + assert.strictEqual(defaultValue, 'context-default'); + + // With context matching a targeting rule, should get the targeted value + const targeted = await env.FLAGS.getStringValue( + 'context-flag', + 'fallback', + { + region: 'eu', + } + ); + assert.strictEqual(targeted, 'eu-variant'); + }, +}; diff --git a/src/cloudflare/internal/test/flags/flags-api-test.wd-test b/src/cloudflare/internal/test/flags/flags-api-test.wd-test index 4c9f78c60d8..d3d52a758d9 100644 --- a/src/cloudflare/internal/test/flags/flags-api-test.wd-test +++ b/src/cloudflare/internal/test/flags/flags-api-test.wd-test @@ -7,7 +7,7 @@ const unitTests :Workerd.Config = ( modules = [ (name = "worker", esModule = embed "flags-api-test.js") ], - compatibilityFlags = ["nodejs_compat"], + compatibilityFlags = ["experimental", "nodejs_compat", "rpc"], bindings = [ ( name = "FLAGS", @@ -15,7 +15,10 @@ const unitTests :Workerd.Config = ( moduleName = "cloudflare-internal:flags-api", innerBindings = [( name = "fetcher", - service = "flags-mock" + service = ( + name = "flags-mock", + entrypoint = "FlagshipBinding" + ) )], ) ) @@ -24,7 +27,7 @@ const unitTests :Workerd.Config = ( ), ( name = "flags-mock", worker = ( - compatibilityFlags = ["experimental", "nodejs_compat"], + compatibilityFlags = ["experimental", "nodejs_compat", "rpc"], modules = [ (name = "worker", esModule = embed "flags-mock.js") ], diff --git a/src/cloudflare/internal/test/flags/flags-mock.js b/src/cloudflare/internal/test/flags/flags-mock.js index f4a50d4103c..a866a115eba 100644 --- a/src/cloudflare/internal/test/flags/flags-mock.js +++ b/src/cloudflare/internal/test/flags/flags-mock.js @@ -2,7 +2,9 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -// Mock flag store for testing. Simulates a backend flags service. +import { WorkerEntrypoint } from 'cloudflare:workers'; + +// Mock flag store for testing. Simulates the FlagshipBinding JSRPC entrypoint. const FLAGS = { 'bool-flag': { value: true, variant: 'on', reason: 'TARGETING_MATCH' }, 'string-flag': { @@ -21,28 +23,32 @@ const FLAGS = { variant: 'default', reason: 'DEFAULT', }, + 'context-flag': { + value: 'context-default', + variant: 'default', + reason: 'DEFAULT', + }, }; -export default { - async fetch(request, _env, _ctx) { - const url = new URL(request.url); - - // Expected path: /flags/ - const match = url.pathname.match(/^\/flags\/(.+)$/); - if (!match) { - return new Response('Not found', { status: 404 }); - } - - const flagKey = decodeURIComponent(match[1]); +export class FlagshipBinding extends WorkerEntrypoint { + /** + * Evaluate a feature flag, optionally using evaluation context for targeting. + * Matches the contract of the real FlagshipBinding entrypoint. + * @param {string} flagKey + * @param {Record} [context] + * @returns {Promise<{value: unknown, variant?: string, reason?: string}>} + */ + async evaluate(flagKey, context) { const flag = FLAGS[flagKey]; - if (!flag) { - return Response.json( - { error: 'FLAG_NOT_FOUND', message: `Flag "${flagKey}" not found` }, - { status: 404 } - ); + throw new Error(`Flag "${flagKey}" not found`); } - return Response.json(flag); - }, -}; + // Simulate context-based targeting for the context-flag + if (flagKey === 'context-flag' && context?.region === 'eu') { + return { value: 'eu-variant', variant: 'eu', reason: 'TARGETING_MATCH' }; + } + + return flag; + } +} diff --git a/types/defines/flags.d.ts b/types/defines/flags.d.ts index e7ae1ecca79..827904bffc3 100644 --- a/types/defines/flags.d.ts +++ b/types/defines/flags.d.ts @@ -1,3 +1,9 @@ +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +export type EvaluationContext = Record; + export interface EvaluationDetails { flagKey: string; value: T; @@ -17,6 +23,12 @@ export interface FlagEvaluationError extends Error {} * // Get a boolean flag value with a default * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * * // Get full evaluation details including variant and reason * const details = await env.FLAGS.getBooleanDetails('my-feature', false); * console.log(details.variant, details.reason); @@ -27,77 +39,107 @@ export declare abstract class Flags { * Get a flag value without type checking. * @param flagKey The key of the flag to evaluate. * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. */ - get(flagKey: string, defaultValue?: unknown): Promise; + get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext + ): Promise; /** * Get a boolean flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext + ): Promise; /** * Get a string flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getStringValue(flagKey: string, defaultValue: string): Promise; + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise; /** * Get a number flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getNumberValue(flagKey: string, defaultValue: number): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise; /** * Get an object flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectValue( flagKey: string, - defaultValue: T + defaultValue: T, + context?: EvaluationContext ): Promise; /** * Get a boolean flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getBooleanDetails( flagKey: string, - defaultValue: boolean + defaultValue: boolean, + context?: EvaluationContext ): Promise>; /** * Get a string flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getStringDetails( flagKey: string, - defaultValue: string + defaultValue: string, + context?: EvaluationContext ): Promise>; /** * Get a number flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getNumberDetails( flagKey: string, - defaultValue: number + defaultValue: number, + context?: EvaluationContext ): Promise>; /** * Get an object flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectDetails( flagKey: string, - defaultValue: T + defaultValue: T, + context?: EvaluationContext ): Promise>; } diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 5995b1cd34e..1c5eb6c0f9b 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -12714,6 +12714,11 @@ declare module "cloudflare:email" { }; export { _EmailMessage as EmailMessage }; } +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +type EvaluationContext = Record; interface EvaluationDetails { flagKey: string; value: T; @@ -12731,6 +12736,12 @@ interface FlagEvaluationError extends Error {} * // Get a boolean flag value with a default * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * * // Get full evaluation details including variant and reason * const details = await env.FLAGS.getBooleanDetails('my-feature', false); * console.log(details.variant, details.reason); @@ -12741,70 +12752,100 @@ declare abstract class Flags { * Get a flag value without type checking. * @param flagKey The key of the flag to evaluate. * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. */ - get(flagKey: string, defaultValue?: unknown): Promise; + get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext, + ): Promise; /** * Get a boolean flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + ): Promise; /** * Get a string flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getStringValue(flagKey: string, defaultValue: string): Promise; + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + ): Promise; /** * Get a number flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getNumberValue(flagKey: string, defaultValue: number): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + ): Promise; /** * Get an object flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectValue( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise; /** * Get a boolean flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getBooleanDetails( flagKey: string, defaultValue: boolean, + context?: EvaluationContext, ): Promise>; /** * Get a string flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getStringDetails( flagKey: string, defaultValue: string, + context?: EvaluationContext, ): Promise>; /** * Get a number flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getNumberDetails( flagKey: string, defaultValue: number, + context?: EvaluationContext, ): Promise>; /** * Get an object flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectDetails( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise>; } /** diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index ffb819dded4..fd25970d554 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -12730,6 +12730,11 @@ export declare type EmailExportedHandler = ( env: Env, ctx: ExecutionContext, ) => void | Promise; +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +export type EvaluationContext = Record; export interface EvaluationDetails { flagKey: string; value: T; @@ -12747,6 +12752,12 @@ export interface FlagEvaluationError extends Error {} * // Get a boolean flag value with a default * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * * // Get full evaluation details including variant and reason * const details = await env.FLAGS.getBooleanDetails('my-feature', false); * console.log(details.variant, details.reason); @@ -12757,70 +12768,100 @@ export declare abstract class Flags { * Get a flag value without type checking. * @param flagKey The key of the flag to evaluate. * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. */ - get(flagKey: string, defaultValue?: unknown): Promise; + get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext, + ): Promise; /** * Get a boolean flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + ): Promise; /** * Get a string flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getStringValue(flagKey: string, defaultValue: string): Promise; + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + ): Promise; /** * Get a number flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getNumberValue(flagKey: string, defaultValue: number): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + ): Promise; /** * Get an object flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectValue( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise; /** * Get a boolean flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getBooleanDetails( flagKey: string, defaultValue: boolean, + context?: EvaluationContext, ): Promise>; /** * Get a string flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getStringDetails( flagKey: string, defaultValue: string, + context?: EvaluationContext, ): Promise>; /** * Get a number flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getNumberDetails( flagKey: string, defaultValue: number, + context?: EvaluationContext, ): Promise>; /** * Get an object flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectDetails( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise>; } /** diff --git a/types/generated-snapshot/latest/index.d.ts b/types/generated-snapshot/latest/index.d.ts index 07ff43c4565..a418c8f0d34 100755 --- a/types/generated-snapshot/latest/index.d.ts +++ b/types/generated-snapshot/latest/index.d.ts @@ -12001,6 +12001,11 @@ declare module "cloudflare:email" { }; export { _EmailMessage as EmailMessage }; } +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +type EvaluationContext = Record; interface EvaluationDetails { flagKey: string; value: T; @@ -12018,6 +12023,12 @@ interface FlagEvaluationError extends Error {} * // Get a boolean flag value with a default * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * * // Get full evaluation details including variant and reason * const details = await env.FLAGS.getBooleanDetails('my-feature', false); * console.log(details.variant, details.reason); @@ -12028,70 +12039,100 @@ declare abstract class Flags { * Get a flag value without type checking. * @param flagKey The key of the flag to evaluate. * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. */ - get(flagKey: string, defaultValue?: unknown): Promise; + get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext, + ): Promise; /** * Get a boolean flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + ): Promise; /** * Get a string flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getStringValue(flagKey: string, defaultValue: string): Promise; + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + ): Promise; /** * Get a number flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getNumberValue(flagKey: string, defaultValue: number): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + ): Promise; /** * Get an object flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectValue( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise; /** * Get a boolean flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getBooleanDetails( flagKey: string, defaultValue: boolean, + context?: EvaluationContext, ): Promise>; /** * Get a string flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getStringDetails( flagKey: string, defaultValue: string, + context?: EvaluationContext, ): Promise>; /** * Get a number flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getNumberDetails( flagKey: string, defaultValue: number, + context?: EvaluationContext, ): Promise>; /** * Get an object flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectDetails( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise>; } /** diff --git a/types/generated-snapshot/latest/index.ts b/types/generated-snapshot/latest/index.ts index 588bf4fde87..0429ae0cd8e 100755 --- a/types/generated-snapshot/latest/index.ts +++ b/types/generated-snapshot/latest/index.ts @@ -12017,6 +12017,11 @@ export declare type EmailExportedHandler = ( env: Env, ctx: ExecutionContext, ) => void | Promise; +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +export type EvaluationContext = Record; export interface EvaluationDetails { flagKey: string; value: T; @@ -12034,6 +12039,12 @@ export interface FlagEvaluationError extends Error {} * // Get a boolean flag value with a default * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * * // Get full evaluation details including variant and reason * const details = await env.FLAGS.getBooleanDetails('my-feature', false); * console.log(details.variant, details.reason); @@ -12044,70 +12055,100 @@ export declare abstract class Flags { * Get a flag value without type checking. * @param flagKey The key of the flag to evaluate. * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. */ - get(flagKey: string, defaultValue?: unknown): Promise; + get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext, + ): Promise; /** * Get a boolean flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getBooleanValue(flagKey: string, defaultValue: boolean): Promise; + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + ): Promise; /** * Get a string flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getStringValue(flagKey: string, defaultValue: string): Promise; + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + ): Promise; /** * Get a number flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ - getNumberValue(flagKey: string, defaultValue: number): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + ): Promise; /** * Get an object flag value. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectValue( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise; /** * Get a boolean flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getBooleanDetails( flagKey: string, defaultValue: boolean, + context?: EvaluationContext, ): Promise>; /** * Get a string flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getStringDetails( flagKey: string, defaultValue: string, + context?: EvaluationContext, ): Promise>; /** * Get a number flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getNumberDetails( flagKey: string, defaultValue: number, + context?: EvaluationContext, ): Promise>; /** * Get an object flag value with full evaluation details. * @param flagKey The key of the flag to evaluate. * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. */ getObjectDetails( flagKey: string, defaultValue: T, + context?: EvaluationContext, ): Promise>; } /** From 22004c5ec5bd4cb3acd14a811e65b28098c4822a Mon Sep 17 00:00:00 2001 From: Rohan Mukherjee Date: Wed, 1 Apr 2026 00:23:07 +0530 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20remove=20runtime=20wrapper=20?= =?UTF-8?q?=E2=80=94=20JSRPC=20binding=20only=20needs=20type=20definitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flags binding is a JSRPC binding handled by the control plane. Only types/defines/flags.d.ts and the generated snapshots are needed in workerd. Remove src/cloudflare/flags.ts, internal/flags-api.ts, and all test files. --- src/cloudflare/flags.ts | 10 - src/cloudflare/internal/flags-api.ts | 191 ------------------ .../internal/test/flags/BUILD.bazel | 7 - .../internal/test/flags/flags-api-test.js | 102 ---------- .../test/flags/flags-api-test.wd-test | 37 ---- .../internal/test/flags/flags-mock.js | 54 ----- 6 files changed, 401 deletions(-) delete mode 100644 src/cloudflare/flags.ts delete mode 100644 src/cloudflare/internal/flags-api.ts delete mode 100644 src/cloudflare/internal/test/flags/BUILD.bazel delete mode 100644 src/cloudflare/internal/test/flags/flags-api-test.js delete mode 100644 src/cloudflare/internal/test/flags/flags-api-test.wd-test delete mode 100644 src/cloudflare/internal/test/flags/flags-mock.js diff --git a/src/cloudflare/flags.ts b/src/cloudflare/flags.ts deleted file mode 100644 index db0b4b78801..00000000000 --- a/src/cloudflare/flags.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: -// https://opensource.org/licenses/Apache-2.0 - -export { - type EvaluationContext, - type EvaluationDetails, - FlagEvaluationError, - Flags, -} from 'cloudflare-internal:flags-api'; diff --git a/src/cloudflare/internal/flags-api.ts b/src/cloudflare/internal/flags-api.ts deleted file mode 100644 index 222d28caf0d..00000000000 --- a/src/cloudflare/internal/flags-api.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: -// https://opensource.org/licenses/Apache-2.0 - -/** - * Stub for the FlagshipBinding JSRPC entrypoint. - * Matches the contract in control-plane/src/binding.ts. - */ -interface FlagshipBindingStub { - evaluate( - flagKey: string, - context?: EvaluationContext - ): Promise<{ - value: unknown; - variant?: string; - reason?: string; - }>; -} - -export type EvaluationContext = Record; - -export interface EvaluationDetails { - flagKey: string; - value: T; - variant?: string | undefined; - reason?: string | undefined; - errorCode?: string | undefined; - errorMessage?: string | undefined; -} - -export class FlagEvaluationError extends Error { - constructor(message: string, name = 'FlagEvaluationError') { - super(message); - this.name = name; - } -} - -export class Flags { - #fetcher: FlagshipBindingStub; - - constructor(fetcher: FlagshipBindingStub) { - this.#fetcher = fetcher; - } - - async get( - flagKey: string, - defaultValue?: unknown, - context?: EvaluationContext - ): Promise { - const details = await this.#evaluate(flagKey, defaultValue, context); - return details.value; - } - - async getBooleanValue( - flagKey: string, - defaultValue: boolean, - context?: EvaluationContext - ): Promise { - const details = await this.getBooleanDetails( - flagKey, - defaultValue, - context - ); - return details.value; - } - - async getStringValue( - flagKey: string, - defaultValue: string, - context?: EvaluationContext - ): Promise { - const details = await this.getStringDetails(flagKey, defaultValue, context); - return details.value; - } - - async getNumberValue( - flagKey: string, - defaultValue: number, - context?: EvaluationContext - ): Promise { - const details = await this.getNumberDetails(flagKey, defaultValue, context); - return details.value; - } - - async getObjectValue( - flagKey: string, - defaultValue: T, - context?: EvaluationContext - ): Promise { - const details = await this.getObjectDetails(flagKey, defaultValue, context); - return details.value; - } - - async getBooleanDetails( - flagKey: string, - defaultValue: boolean, - context?: EvaluationContext - ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue, context); - if (typeof details.value !== 'boolean') { - return { - ...details, - value: defaultValue, - errorCode: 'TYPE_MISMATCH', - errorMessage: `Expected boolean but got ${typeof details.value}`, - }; - } - return details as EvaluationDetails; - } - - async getStringDetails( - flagKey: string, - defaultValue: string, - context?: EvaluationContext - ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue, context); - if (typeof details.value !== 'string') { - return { - ...details, - value: defaultValue, - errorCode: 'TYPE_MISMATCH', - errorMessage: `Expected string but got ${typeof details.value}`, - }; - } - return details as EvaluationDetails; - } - - async getNumberDetails( - flagKey: string, - defaultValue: number, - context?: EvaluationContext - ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue, context); - if (typeof details.value !== 'number') { - return { - ...details, - value: defaultValue, - errorCode: 'TYPE_MISMATCH', - errorMessage: `Expected number but got ${typeof details.value}`, - }; - } - return details as EvaluationDetails; - } - - async getObjectDetails( - flagKey: string, - defaultValue: T, - context?: EvaluationContext - ): Promise> { - const details = await this.#evaluate(flagKey, defaultValue, context); - if (typeof details.value !== 'object' || details.value === null) { - return { - ...details, - value: defaultValue, - errorCode: 'TYPE_MISMATCH', - errorMessage: `Expected object but got ${typeof details.value}`, - }; - } - return details as EvaluationDetails; - } - - async #evaluate( - flagKey: string, - defaultValue: unknown, - context?: EvaluationContext - ): Promise> { - try { - const result = await this.#fetcher.evaluate(flagKey, context); - - return { - flagKey, - value: result.value, - variant: result.variant, - reason: result.reason, - }; - } catch (err) { - return { - flagKey, - value: defaultValue, - errorCode: 'GENERAL', - errorMessage: err instanceof Error ? err.message : String(err), - }; - } - } -} - -export default function makeBinding(env: { - fetcher: FlagshipBindingStub; -}): Flags { - return new Flags(env.fetcher); -} diff --git a/src/cloudflare/internal/test/flags/BUILD.bazel b/src/cloudflare/internal/test/flags/BUILD.bazel deleted file mode 100644 index 9d0ede61585..00000000000 --- a/src/cloudflare/internal/test/flags/BUILD.bazel +++ /dev/null @@ -1,7 +0,0 @@ -load("//:build/wd_test.bzl", "wd_test") - -wd_test( - src = "flags-api-test.wd-test", - args = ["--experimental"], - data = glob(["*.js"]), -) diff --git a/src/cloudflare/internal/test/flags/flags-api-test.js b/src/cloudflare/internal/test/flags/flags-api-test.js deleted file mode 100644 index 73ba750b4e5..00000000000 --- a/src/cloudflare/internal/test/flags/flags-api-test.js +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: -// https://opensource.org/licenses/Apache-2.0 - -import assert from 'node:assert'; - -export const getBooleanValueTest = { - async test(_, env) { - const value = await env.FLAGS.getBooleanValue('bool-flag', false); - assert.strictEqual(value, true); - }, -}; - -export const getStringValueTest = { - async test(_, env) { - const value = await env.FLAGS.getStringValue('string-flag', 'default'); - assert.strictEqual(value, 'variant-a'); - }, -}; - -export const getNumberValueTest = { - async test(_, env) { - const value = await env.FLAGS.getNumberValue('number-flag', 0); - assert.strictEqual(value, 42); - }, -}; - -export const getObjectValueTest = { - async test(_, env) { - const value = await env.FLAGS.getObjectValue('object-flag', {}); - assert.deepStrictEqual(value, { color: 'blue', size: 10 }); - }, -}; - -export const getGenericTest = { - async test(_, env) { - const value = await env.FLAGS.get('bool-flag'); - assert.strictEqual(value, true); - }, -}; - -export const getBooleanDetailsTest = { - async test(_, env) { - const details = await env.FLAGS.getBooleanDetails('bool-flag', false); - assert.strictEqual(details.flagKey, 'bool-flag'); - assert.strictEqual(details.value, true); - assert.strictEqual(details.variant, 'on'); - assert.strictEqual(details.reason, 'TARGETING_MATCH'); - assert.strictEqual(details.errorCode, undefined); - }, -}; - -export const typeMismatchReturnsDefaultTest = { - async test(_, env) { - // type-mismatch-flag returns a string, but we ask for boolean - const details = await env.FLAGS.getBooleanDetails( - 'type-mismatch-flag', - false - ); - assert.strictEqual(details.value, false); // default value - assert.strictEqual(details.errorCode, 'TYPE_MISMATCH'); - }, -}; - -export const missingFlagReturnsDefaultTest = { - async test(_, env) { - const value = await env.FLAGS.getBooleanValue('nonexistent-flag', false); - assert.strictEqual(value, false); - }, -}; - -export const getDefaultValueOnErrorTest = { - async test(_, env) { - const details = await env.FLAGS.getStringDetails( - 'nonexistent-flag', - 'fallback' - ); - assert.strictEqual(details.value, 'fallback'); - assert.strictEqual(details.errorCode, 'GENERAL'); - }, -}; - -export const evaluationContextTest = { - async test(_, env) { - // Without context, should get the default value - const defaultValue = await env.FLAGS.getStringValue( - 'context-flag', - 'fallback' - ); - assert.strictEqual(defaultValue, 'context-default'); - - // With context matching a targeting rule, should get the targeted value - const targeted = await env.FLAGS.getStringValue( - 'context-flag', - 'fallback', - { - region: 'eu', - } - ); - assert.strictEqual(targeted, 'eu-variant'); - }, -}; diff --git a/src/cloudflare/internal/test/flags/flags-api-test.wd-test b/src/cloudflare/internal/test/flags/flags-api-test.wd-test deleted file mode 100644 index d3d52a758d9..00000000000 --- a/src/cloudflare/internal/test/flags/flags-api-test.wd-test +++ /dev/null @@ -1,37 +0,0 @@ -using Workerd = import "/workerd/workerd.capnp"; - -const unitTests :Workerd.Config = ( - services = [ - ( name = "flags-api-test", - worker = ( - modules = [ - (name = "worker", esModule = embed "flags-api-test.js") - ], - compatibilityFlags = ["experimental", "nodejs_compat", "rpc"], - bindings = [ - ( - name = "FLAGS", - wrapped = ( - moduleName = "cloudflare-internal:flags-api", - innerBindings = [( - name = "fetcher", - service = ( - name = "flags-mock", - entrypoint = "FlagshipBinding" - ) - )], - ) - ) - ], - ) - ), - ( name = "flags-mock", - worker = ( - compatibilityFlags = ["experimental", "nodejs_compat", "rpc"], - modules = [ - (name = "worker", esModule = embed "flags-mock.js") - ], - ) - ) - ] -); diff --git a/src/cloudflare/internal/test/flags/flags-mock.js b/src/cloudflare/internal/test/flags/flags-mock.js deleted file mode 100644 index a866a115eba..00000000000 --- a/src/cloudflare/internal/test/flags/flags-mock.js +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: -// https://opensource.org/licenses/Apache-2.0 - -import { WorkerEntrypoint } from 'cloudflare:workers'; - -// Mock flag store for testing. Simulates the FlagshipBinding JSRPC entrypoint. -const FLAGS = { - 'bool-flag': { value: true, variant: 'on', reason: 'TARGETING_MATCH' }, - 'string-flag': { - value: 'variant-a', - variant: 'a', - reason: 'TARGETING_MATCH', - }, - 'number-flag': { value: 42, variant: 'fourty-two', reason: 'DEFAULT' }, - 'object-flag': { - value: { color: 'blue', size: 10 }, - variant: 'blue-10', - reason: 'SPLIT', - }, - 'type-mismatch-flag': { - value: 'not-a-boolean', - variant: 'default', - reason: 'DEFAULT', - }, - 'context-flag': { - value: 'context-default', - variant: 'default', - reason: 'DEFAULT', - }, -}; - -export class FlagshipBinding extends WorkerEntrypoint { - /** - * Evaluate a feature flag, optionally using evaluation context for targeting. - * Matches the contract of the real FlagshipBinding entrypoint. - * @param {string} flagKey - * @param {Record} [context] - * @returns {Promise<{value: unknown, variant?: string, reason?: string}>} - */ - async evaluate(flagKey, context) { - const flag = FLAGS[flagKey]; - if (!flag) { - throw new Error(`Flag "${flagKey}" not found`); - } - - // Simulate context-based targeting for the context-flag - if (flagKey === 'context-flag' && context?.region === 'eu') { - return { value: 'eu-variant', variant: 'eu', reason: 'TARGETING_MATCH' }; - } - - return flag; - } -}