diff --git a/Cargo.lock b/Cargo.lock index da92b199..c2131dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,6 +881,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagship-on-workers" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "worker", +] + [[package]] name = "flate2" version = "1.1.9" diff --git a/chompfile.toml b/chompfile.toml index fb123fa7..39522b07 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -2,6 +2,10 @@ version = 0.1 [[task]] name = 'build:types' +deps = ['build:types:email', 'build:types:flagship'] + +[[task]] +name = 'build:types:email' deps = ['install:ts-gen'] # `Env` / `ExecutionContext` are project-specific re-exports that ts-gen # can't infer; everything else (`ReadableStream`, `Headers`, `Event`, …) @@ -10,6 +14,11 @@ run = '''ts-gen --input types/email.d.ts --output worker/src/email.rs \ --external "Env=crate::Env" \ --external "ExecutionContext=crate::Context"''' +[[task]] +name = 'build:types:flagship' +deps = ['install:ts-gen'] +run = 'ts-gen --input types/flagship.d.ts --output worker/src/flagship_gen.rs' + [[task]] name = 'install:ts-gen' # ts-gen pulls in oxc which needs a newer rustc than the workspace's pinned 1.88; diff --git a/examples/flagship/Cargo.toml b/examples/flagship/Cargo.toml new file mode 100644 index 00000000..03fca2b6 --- /dev/null +++ b/examples/flagship/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "flagship-on-workers" +version = "0.1.0" +edition = "2021" + +[package.metadata.release] +release = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +worker.workspace = true diff --git a/examples/flagship/src/lib.rs b/examples/flagship/src/lib.rs new file mode 100644 index 00000000..5b765d14 --- /dev/null +++ b/examples/flagship/src/lib.rs @@ -0,0 +1,107 @@ +//! Example demonstrating Cloudflare Flagship (feature flags) from a Rust Worker. +//! +//! Routes: +//! * `/boolean?flag=` — evaluate a boolean flag +//! * `/string?flag=` — evaluate a string flag, optionally with `?userId=` context +//! * `/object?flag=` — evaluate an object flag into a typed struct +//! * `/details?flag=` — return the full evaluation details envelope + +use serde::{Deserialize, Serialize}; +use worker::{event, Env, EvaluationContext, Request, Response, Result, Router, Url}; + +const BINDING: &str = "FLAGS"; + +#[derive(Serialize, Deserialize)] +struct Theme { + primary: String, + secondary: String, +} + +#[event(fetch)] +async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result { + Router::new() + .get_async( + "/boolean", + |req, ctx| async move { boolean(req, ctx.env).await }, + ) + .get_async( + "/string", + |req, ctx| async move { string(req, ctx.env).await }, + ) + .get_async( + "/object", + |req, ctx| async move { object(req, ctx.env).await }, + ) + .get_async( + "/details", + |req, ctx| async move { details(req, ctx.env).await }, + ) + .run(req, env) + .await +} + +async fn boolean(req: Request, env: Env) -> Result { + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "example-bool".into()); + let value: bool = env + .flagship(BINDING)? + .get_boolean_value(&flag, false) + .await? + .value_of(); + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +async fn string(req: Request, env: Env) -> Result { + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "checkout-flow".into()); + let flagship = env.flagship(BINDING)?; + let value = String::from(match query(&url, "userId") { + Some(user_id) => { + let ctx = EvaluationContext::new() + .string("userId", &user_id) + .string("country", "US"); + flagship + .get_string_value_with_context(&flag, "control", ctx.as_ref()) + .await? + } + None => flagship.get_string_value(&flag, "control").await?, + }); + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +async fn object(req: Request, env: Env) -> Result { + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "theme".into()); + let default = Theme { + primary: "#000000".into(), + secondary: "#ffffff".into(), + }; + let value: Theme = env + .flagship(BINDING)? + .get_object_value(&flag, &default) + .await?; + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +async fn details(req: Request, env: Env) -> Result { + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "checkout-flow".into()); + let details = env + .flagship(BINDING)? + .get_string_details(&flag, "control") + .await?; + Response::from_json(&serde_json::json!({ + "flagKey": details.flag_key(), + "value": details.value().as_string(), + "variant": details.variant(), + "reason": details.reason(), + "errorCode": details.error_code(), + "errorMessage": details.error_message(), + })) +} + +fn query(url: &Url, key: &str) -> Option { + url.query_pairs() + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) +} diff --git a/examples/flagship/wrangler.toml b/examples/flagship/wrangler.toml new file mode 100644 index 00000000..0a108fea --- /dev/null +++ b/examples/flagship/wrangler.toml @@ -0,0 +1,12 @@ +name = "flagship-worker" +main = "build/worker/shim.mjs" +compatibility_date = "2026-04-17" + +[build] +command = "cargo install worker-build@^0.8 && worker-build --release" + +# Replace with your Flagship app ID from the Cloudflare dashboard. +# https://developers.cloudflare.com/flagship/get-started/ +[[flagship]] +binding = "FLAGS" +app_id = "" diff --git a/test/src/flagship.rs b/test/src/flagship.rs new file mode 100644 index 00000000..bcfecf27 --- /dev/null +++ b/test/src/flagship.rs @@ -0,0 +1,172 @@ +use crate::SomeSharedData; +use serde::{Deserialize, Serialize}; +use worker::wasm_bindgen::JsValue; +use worker::{ + Env, EvaluationContext, EvaluationDetails, FlagshipEvaluationDetails, Request, Response, Result, +}; + +const BINDING: &str = "FLAGS"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Theme { + primary: String, + secondary: String, +} + +fn default_theme() -> Theme { + Theme { + primary: "#000000".to_string(), + secondary: "#ffffff".to_string(), + } +} + +fn last_segment(req: &Request) -> Result { + let url = req.url()?; + Ok(url + .path_segments() + .and_then(|mut s| s.next_back().map(str::to_owned)) + .unwrap_or_default()) +} + +#[worker::send] +pub async fn handle_boolean(req: Request, env: Env, _data: SomeSharedData) -> Result { + let flag = last_segment(&req)?; + let value: bool = env + .flagship(BINDING)? + .get_boolean_value(&flag, false) + .await? + .value_of(); + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +#[worker::send] +pub async fn handle_string(req: Request, env: Env, _data: SomeSharedData) -> Result { + let flag = last_segment(&req)?; + let value = String::from( + env.flagship(BINDING)? + .get_string_value(&flag, "fallback") + .await?, + ); + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +#[worker::send] +pub async fn handle_number(req: Request, env: Env, _data: SomeSharedData) -> Result { + let flag = last_segment(&req)?; + let value: f64 = env + .flagship(BINDING)? + .get_number_value(&flag, 0.0) + .await? + .value_of(); + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +#[worker::send] +pub async fn handle_object(req: Request, env: Env, _data: SomeSharedData) -> Result { + let flag = last_segment(&req)?; + let value: Theme = env + .flagship(BINDING)? + .get_object_value(&flag, &default_theme()) + .await?; + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +#[worker::send] +pub async fn handle_get(req: Request, env: Env, _data: SomeSharedData) -> Result { + let flag = last_segment(&req)?; + let default = JsValue::from_str("fallback"); + let raw = env + .flagship(BINDING)? + .get_with_default_value(&flag, &default) + .await?; + let value: serde_json::Value = serde_wasm_bindgen::from_value(raw)?; + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +#[worker::send] +pub async fn handle_context(req: Request, env: Env, _data: SomeSharedData) -> Result { + let user_id = last_segment(&req)?; + let eval_ctx = EvaluationContext::new() + .string("userId", &user_id) + .number("age", 30.0) + .bool("premium", true); + let value = String::from( + env.flagship(BINDING)? + .get_string_value_with_context("user-branch", "default", eval_ctx.as_ref()) + .await?, + ); + Response::from_json(&serde_json::json!({ "userId": user_id, "value": value })) +} + +#[worker::send] +pub async fn handle_boolean_details( + req: Request, + env: Env, + _data: SomeSharedData, +) -> Result { + let flag = last_segment(&req)?; + let details = env + .flagship(BINDING)? + .get_boolean_details(&flag, false) + .await?; + let value = details.value().as_bool(); + Response::from_json(&details_to_json(&details, value)) +} + +#[worker::send] +pub async fn handle_string_details( + req: Request, + env: Env, + _data: SomeSharedData, +) -> Result { + let flag = last_segment(&req)?; + let details = env + .flagship(BINDING)? + .get_string_details(&flag, "fallback") + .await?; + let value = details.value().as_string(); + Response::from_json(&details_to_json(&details, value)) +} + +#[worker::send] +pub async fn handle_number_details( + req: Request, + env: Env, + _data: SomeSharedData, +) -> Result { + let flag = last_segment(&req)?; + let details = env + .flagship(BINDING)? + .get_number_details(&flag, 0.0) + .await?; + let value = details.value().as_f64(); + Response::from_json(&details_to_json(&details, value)) +} + +#[worker::send] +pub async fn handle_object_details( + req: Request, + env: Env, + _data: SomeSharedData, +) -> Result { + let flag = last_segment(&req)?; + let details: EvaluationDetails = env + .flagship(BINDING)? + .get_object_details(&flag, &default_theme()) + .await?; + Response::from_json(&details) +} + +fn details_to_json( + details: &FlagshipEvaluationDetails, + value: T, +) -> serde_json::Value { + serde_json::json!({ + "flagKey": details.flag_key(), + "value": value, + "variant": details.variant(), + "reason": details.reason(), + "errorCode": details.error_code(), + "errorMessage": details.error_message(), + }) +} diff --git a/test/src/lib.rs b/test/src/lib.rs index 37f718fd..090921b0 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -22,6 +22,7 @@ mod counter; mod d1; mod durable; mod fetch; +mod flagship; mod form; mod js_snippets; mod kv; diff --git a/test/src/router.rs b/test/src/router.rs index b5a4b56b..e61e6947 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -1,9 +1,9 @@ use crate::signal; use crate::{ alarm, analytics_engine, assets, auto_response, cache, container, counter, d1, durable, fetch, - form, js_snippets, kv, put_raw, queue, r2, rate_limit, request, secret_store, send_email, - service, socket, sql_counter, sql_iterator, user, ws, SomeSharedData, GLOBAL_SECOND_START, - GLOBAL_STATE, + flagship, form, js_snippets, kv, put_raw, queue, r2, rate_limit, request, secret_store, + send_email, service, socket, sql_counter, sql_iterator, user, ws, SomeSharedData, + GLOBAL_SECOND_START, GLOBAL_STATE, }; #[cfg(feature = "http")] use std::convert::TryInto; @@ -241,6 +241,16 @@ macro_rules! add_routes ( add_route!($obj, get, format_route!("/rate-limit/key/{}", "key"), rate_limit::handle_rate_limit_with_key); add_route!($obj, get, "/rate-limit/bulk-test", rate_limit::handle_rate_limit_bulk_test); add_route!($obj, get, "/rate-limit/reset", rate_limit::handle_rate_limit_reset); + add_route!($obj, get, format_route!("/flagship/bool/{}", "flag"), flagship::handle_boolean); + add_route!($obj, get, format_route!("/flagship/string/{}", "flag"), flagship::handle_string); + add_route!($obj, get, format_route!("/flagship/number/{}", "flag"), flagship::handle_number); + add_route!($obj, get, format_route!("/flagship/object/{}", "flag"), flagship::handle_object); + add_route!($obj, get, format_route!("/flagship/get/{}", "flag"), flagship::handle_get); + add_route!($obj, get, format_route!("/flagship/context/{}", "userId"), flagship::handle_context); + add_route!($obj, get, format_route!("/flagship/details/bool/{}", "flag"), flagship::handle_boolean_details); + add_route!($obj, get, format_route!("/flagship/details/string/{}", "flag"), flagship::handle_string_details); + add_route!($obj, get, format_route!("/flagship/details/number/{}", "flag"), flagship::handle_number_details); + add_route!($obj, get, format_route!("/flagship/details/object/{}", "flag"), flagship::handle_object_details); add_route!($obj, get, "/send-email", send_email::handle_send_email); add_route!($obj, get, "/signal/poll", signal::handle_signal_poll); }); diff --git a/test/tests/flagship.spec.ts b/test/tests/flagship.spec.ts new file mode 100644 index 00000000..96a6f2df --- /dev/null +++ b/test/tests/flagship.spec.ts @@ -0,0 +1,121 @@ +import { describe, test, expect } from "vitest"; +import { mf, mfUrl } from "./mf"; + +type Details = { + flagKey: string; + value: T; + variant: string | null; + reason: string | null; + errorCode: string | null; + errorMessage: string | null; +}; + +async function json(path: string): Promise { + const resp = await mf.dispatchFetch(`${mfUrl}${path}`); + expect(resp.status).toBe(200); + return (await resp.json()) as T; +} + +describe("flagship", () => { + describe("value methods", () => { + test("get_boolean_value resolves a known flag", async () => { + const data = await json<{ flag: string; value: boolean }>("flagship/bool/dark-mode"); + expect(data.value).toBe(true); + }); + + test("get_boolean_value returns the default for unknown flags", async () => { + const data = await json<{ flag: string; value: boolean }>("flagship/bool/missing"); + expect(data.value).toBe(false); + }); + + test("get_string_value resolves a known flag", async () => { + const data = await json<{ flag: string; value: string }>("flagship/string/checkout-flow"); + expect(data.value).toBe("v2"); + }); + + test("get_string_value returns the default for unknown flags", async () => { + const data = await json<{ flag: string; value: string }>("flagship/string/missing"); + expect(data.value).toBe("fallback"); + }); + + test("get_number_value resolves a known flag", async () => { + const data = await json<{ flag: string; value: number }>("flagship/number/max-retries"); + expect(data.value).toBe(5); + }); + + test("get_number_value returns the default for unknown flags", async () => { + const data = await json<{ flag: string; value: number }>("flagship/number/missing"); + expect(data.value).toBe(0); + }); + + test("get_object_value deserializes into a typed struct", async () => { + const data = await json<{ flag: string; value: { primary: string; secondary: string } }>( + "flagship/object/theme-colors", + ); + expect(data.value).toEqual({ primary: "#ff0000", secondary: "#00ff00" }); + }); + + test("get_object_value returns the default for unknown flags", async () => { + const data = await json<{ flag: string; value: { primary: string; secondary: string } }>( + "flagship/object/missing", + ); + expect(data.value).toEqual({ primary: "#000000", secondary: "#ffffff" }); + }); + + test("get (untyped) round-trips arbitrary JSON", async () => { + const data = await json<{ flag: string; value: unknown }>("flagship/get/theme-colors"); + expect(data.value).toEqual({ primary: "#ff0000", secondary: "#00ff00" }); + }); + }); + + describe("evaluation context", () => { + test("context routing picks the targeted branch", async () => { + const data = await json<{ userId: string; value: string }>("flagship/context/alice"); + expect(data.value).toBe("alice-branch"); + }); + + test("context routing falls back when targeting misses", async () => { + const data = await json<{ userId: string; value: string }>("flagship/context/bob"); + expect(data.value).toBe("default"); + }); + }); + + describe("details methods", () => { + test("boolean details include variant + reason on a match", async () => { + const details = await json>("flagship/details/bool/dark-mode"); + expect(details.flagKey).toBe("dark-mode"); + expect(details.value).toBe(true); + expect(details.variant).toBe("on"); + expect(details.reason).toBe("TARGETING_MATCH"); + expect(details.errorCode).toBeNull(); + }); + + test("boolean details surface errorCode on miss", async () => { + const details = await json>("flagship/details/bool/missing"); + expect(details.value).toBe(false); + expect(details.errorCode).toBe("GENERAL"); + expect(details.errorMessage).toBe("flag not found"); + expect(details.reason).toBe("DEFAULT"); + }); + + test("string details include variant metadata", async () => { + const details = await json>("flagship/details/string/checkout-flow"); + expect(details.value).toBe("v2"); + expect(details.variant).toBe("rollout-25"); + }); + + test("number details round-trip numeric payloads", async () => { + const details = await json>("flagship/details/number/max-retries"); + expect(details.value).toBe(5); + expect(details.variant).toBe("bumped"); + }); + + test("object details deserialize into a typed struct", async () => { + const details = await json>( + "flagship/details/object/theme-colors", + ); + expect(details.value).toEqual({ primary: "#ff0000", secondary: "#00ff00" }); + expect(details.variant).toBe("brand-v2"); + }); + }); +}); diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 8d8003e9..e8af6ac6 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -110,6 +110,9 @@ const mf_instance = new Miniflare({ wrappedBindings: { HTTP_ANALYTICS: { scriptName: "mini-analytics-engine" // mock out analytics engine binding to the "mini-analytics-engine" worker + }, + FLAGS: { + scriptName: "mini-flagship" // mock out Flagship binding to the "mini-flagship" worker } }, ratelimits: { @@ -140,6 +143,65 @@ const mf_instance = new Miniflare({ } } }` + }, + { + name: "mini-flagship", + modules: true, + // A deterministic stand-in for the Flagship binding. Known flags resolve to fixed values; + // anything else returns the supplied default. The *Details methods round-trip targeting + // metadata (variant/reason) so the Rust wrapper's EvaluationDetails can be asserted. + script: ` + const KNOWN = { + "dark-mode": { type: "boolean", value: true, variant: "on" }, + "checkout-flow": { type: "string", value: "v2", variant: "rollout-25" }, + "max-retries": { type: "number", value: 5, variant: "bumped" }, + "theme-colors": { type: "object", value: { primary: "#ff0000", secondary: "#00ff00" }, variant: "brand-v2" }, + }; + function resolve(flagKey, expectedType, defaultValue, context) { + const flag = KNOWN[flagKey]; + if (flagKey === "user-branch" && context && context.userId === "alice") { + return { value: "alice-branch", variant: "alice", reason: "TARGETING_MATCH" }; + } + if (!flag) { + return { value: defaultValue, reason: "DEFAULT", errorCode: "GENERAL", errorMessage: "flag not found" }; + } + if (flag.type !== expectedType) { + return { value: defaultValue, reason: "ERROR", errorCode: "TYPE_MISMATCH", errorMessage: "flag type mismatch" }; + } + return { value: flag.value, variant: flag.variant, reason: "TARGETING_MATCH" }; + } + export default function (env) { + return { + async get(flagKey, defaultValue, context) { + return KNOWN[flagKey]?.value ?? defaultValue; + }, + async getBooleanValue(flagKey, defaultValue, context) { + return resolve(flagKey, "boolean", defaultValue, context).value; + }, + async getStringValue(flagKey, defaultValue, context) { + return resolve(flagKey, "string", defaultValue, context).value; + }, + async getNumberValue(flagKey, defaultValue, context) { + return resolve(flagKey, "number", defaultValue, context).value; + }, + async getObjectValue(flagKey, defaultValue, context) { + return resolve(flagKey, "object", defaultValue, context).value; + }, + async getBooleanDetails(flagKey, defaultValue, context) { + return { flagKey, ...resolve(flagKey, "boolean", defaultValue, context) }; + }, + async getStringDetails(flagKey, defaultValue, context) { + return { flagKey, ...resolve(flagKey, "string", defaultValue, context) }; + }, + async getNumberDetails(flagKey, defaultValue, context) { + return { flagKey, ...resolve(flagKey, "number", defaultValue, context) }; + }, + async getObjectDetails(flagKey, defaultValue, context) { + return { flagKey, ...resolve(flagKey, "object", defaultValue, context) }; + }, + }; + } + ` }] }); diff --git a/ts-gen b/ts-gen index 1f3ca2de..2df0c150 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit 1f3ca2dea011b5a82a9a353f22c855ef52b511d8 +Subproject commit 2df0c15096272d0ac397719c9a08204f1a56c2c2 diff --git a/types/flagship.d.ts b/types/flagship.d.ts new file mode 100644 index 00000000..ba56fa1c --- /dev/null +++ b/types/flagship.d.ts @@ -0,0 +1,130 @@ +/* + * Flagship binding types from @cloudflare/workers-types. Mirrors + * workerd/types/defines/flagship.d.ts (valid as of 28/04/2026). This + * file builds worker/src/flagship_gen.rs as auto-generated bindings + * via ts-gen. + * + * NOTE: All hand edits to the upstream types are marked with an + * "EDIT:" comment. + */ + +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +type FlagshipEvaluationContext = Record; + +interface FlagshipEvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} + +// EDIT: dropped empty `interface FlagshipEvaluationError extends Error {}`; +// errors round-trip through `JsValue`. + +/** + * 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 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); + * ``` + */ +declare abstract class Flagship { + /** + * 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, + context?: FlagshipEvaluationContext, + ): 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, + context?: FlagshipEvaluationContext, + ): 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, + context?: FlagshipEvaluationContext, + ): 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, + context?: FlagshipEvaluationContext, + ): Promise; + // EDIT: `getObjectValue(...)` is hand-written in + // worker/src/flagship.rs (ts-gen erases the generic to JsValue). + /** + * 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?: FlagshipEvaluationContext, + ): 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?: FlagshipEvaluationContext, + ): 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?: FlagshipEvaluationContext, + ): Promise>; + // EDIT: `getObjectDetails(...)` is hand-written in + // worker/src/flagship.rs (returns a typed `EvaluationDetails`). +} diff --git a/worker/src/env.rs b/worker/src/env.rs index 72502d96..5bc64ca6 100644 --- a/worker/src/env.rs +++ b/worker/src/env.rs @@ -3,6 +3,7 @@ use std::fmt::Display; use crate::analytics_engine::AnalyticsEngineDataset; #[cfg(feature = "d1")] use crate::d1::D1Database; +use crate::flagship::Flagship; use crate::kv::KvStore; use crate::rate_limit::RateLimiter; use crate::send_email::SendEmail; @@ -130,6 +131,12 @@ impl Env { self.get_binding(binding) } + /// Access a [Flagship](https://developers.cloudflare.com/flagship/) feature-flag store by + /// the binding name configured in your wrangler.toml file. + pub fn flagship(&self, binding: &str) -> Result { + self.get_binding(binding) + } + /// Access a [send_email binding](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/) /// configured under `[[send_email]]` in your `wrangler.toml`. Use the /// returned [`SendEmail`] to dispatch either a structured diff --git a/worker/src/flagship.rs b/worker/src/flagship.rs new file mode 100644 index 00000000..87cb3d68 --- /dev/null +++ b/worker/src/flagship.rs @@ -0,0 +1,184 @@ +use crate::{send::SendFuture, EnvBinding, Result}; +use js_sys::{Object, Promise}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +// Hand-written companion to `flagship_gen.rs`. ts-gen erases +// `` to `JsValue`, so the typed `get_object_*` methods +// live here with serde conversions folded in. `EvaluationContext` and the +// `EnvBinding` impl are also here because ts-gen doesn't synthesize them. +pub use crate::flagship_gen::{Flagship, FlagshipEvaluationDetails}; + +impl EnvBinding for Flagship { + const TYPE_NAME: &'static str = "Flagship"; + + // Miniflare's `wrappedBindings` expose the binding as a plain `Object`, + // so the default `constructor.name` check fails under local dev. + fn get(val: JsValue) -> Result { + Ok(val.unchecked_into()) + } +} + +// Object-typed methods stripped from `flagship.d.ts` (ts-gen erases ``). +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(method, js_name = "getObjectValue")] + fn get_object_value_raw(this: &Flagship, flag_key: &str, default_value: &JsValue) -> Promise; + + #[wasm_bindgen(method, js_name = "getObjectValue")] + fn get_object_value_with_context_raw( + this: &Flagship, + flag_key: &str, + default_value: &JsValue, + context: &Object, + ) -> Promise; + + #[wasm_bindgen(method, js_name = "getObjectDetails")] + fn get_object_details_raw(this: &Flagship, flag_key: &str, default_value: &JsValue) -> Promise; + + #[wasm_bindgen(method, js_name = "getObjectDetails")] + fn get_object_details_with_context_raw( + this: &Flagship, + flag_key: &str, + default_value: &JsValue, + context: &Object, + ) -> Promise; +} + +impl Flagship { + /// Evaluate an object-typed flag, returning the resolved value + /// deserialized into `T`. + pub async fn get_object_value( + &self, + flag_key: &str, + default_value: &T, + ) -> Result { + call_object(default_value, |default| { + self.get_object_value_raw(flag_key, default) + }) + .await + } + + /// Evaluate an object-typed flag with a targeting context. + pub async fn get_object_value_with_context( + &self, + flag_key: &str, + default_value: &T, + context: &Object, + ) -> Result { + call_object(default_value, |default| { + self.get_object_value_with_context_raw(flag_key, default, context) + }) + .await + } + + /// Evaluate an object-typed flag and return the full evaluation + /// envelope (variant, reason, error code) with `value` deserialized + /// into `T`. + pub async fn get_object_details( + &self, + flag_key: &str, + default_value: &T, + ) -> Result> { + call_object(default_value, |default| { + self.get_object_details_raw(flag_key, default) + }) + .await + } + + /// Evaluate an object-typed flag with a targeting context, returning + /// the full evaluation envelope. + pub async fn get_object_details_with_context( + &self, + flag_key: &str, + default_value: &T, + context: &Object, + ) -> Result> { + call_object(default_value, |default| { + self.get_object_details_with_context_raw(flag_key, default, context) + }) + .await + } +} + +async fn call_object( + default_value: &I, + promise_fn: impl FnOnce(&JsValue) -> Promise, +) -> Result { + let default = serde_wasm_bindgen::to_value(default_value)?; + let raw = SendFuture::new(JsFuture::from(promise_fn(&default))).await?; + Ok(serde_wasm_bindgen::from_value(raw)?) +} + +/// Typed evaluation record returned by [`Flagship::get_object_details`]. +/// For boolean / string / number flags, the auto-generated +/// [`FlagshipEvaluationDetails`] is used instead. +/// +/// `error_code` and `error_message` are only populated when evaluation +/// fell back to `default_value`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluationDetails { + pub flag_key: String, + pub value: T, + #[serde(default)] + pub variant: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub error_code: Option, + #[serde(default)] + pub error_message: Option, +} + +/// Evaluation attributes passed to Flagship for targeting rules. Values are +/// constrained to `string`, `number`, and `boolean` to match the JS +/// `Record`. +/// +/// Pass via `.as_ref()` to any `_with_context` method, e.g. +/// [`Flagship::get_boolean_value_with_context`] or +/// [`Flagship::get_object_value_with_context`]. +#[derive(Debug, Clone)] +pub struct EvaluationContext { + inner: Object, +} + +impl Default for EvaluationContext { + fn default() -> Self { + Self::new() + } +} + +impl EvaluationContext { + pub fn new() -> Self { + Self { + inner: Object::new(), + } + } + + pub fn string(self, key: &str, value: &str) -> Self { + self.set(key, &JsValue::from_str(value)); + self + } + + pub fn number(self, key: &str, value: f64) -> Self { + self.set(key, &JsValue::from_f64(value)); + self + } + + pub fn bool(self, key: &str, value: bool) -> Self { + self.set(key, &JsValue::from_bool(value)); + self + } + + fn set(&self, key: &str, value: &JsValue) { + let _ = js_sys::Reflect::set(&self.inner, &JsValue::from_str(key), value); + } +} + +impl AsRef for EvaluationContext { + fn as_ref(&self) -> &Object { + &self.inner + } +} diff --git a/worker/src/flagship_gen.rs b/worker/src/flagship_gen.rs new file mode 100644 index 00000000..058966ff --- /dev/null +++ b/worker/src/flagship_gen.rs @@ -0,0 +1,309 @@ +#[allow(unused_imports)] +use js_sys::*; +#[allow(unused_imports)] +use wasm_bindgen::prelude::*; +#[allow(dead_code)] +use JsValue as T; +#[allow(dead_code)] +pub type FlagshipEvaluationContext = Object; +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type FlagshipEvaluationDetails; + #[wasm_bindgen(method, getter, js_name = "flagKey")] + pub fn flag_key(this: &FlagshipEvaluationDetails) -> String; + #[wasm_bindgen(method, setter, js_name = "flagKey")] + pub fn set_flag_key(this: &FlagshipEvaluationDetails, val: &str); + #[wasm_bindgen(method, getter)] + pub fn value(this: &FlagshipEvaluationDetails) -> T; + #[wasm_bindgen(method, setter)] + pub fn set_value(this: &FlagshipEvaluationDetails, val: &T); + #[wasm_bindgen(method, getter)] + pub fn variant(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_variant(this: &FlagshipEvaluationDetails, val: &str); + #[wasm_bindgen(method, setter, js_name = "variant")] + pub fn set_variant_with_null(this: &FlagshipEvaluationDetails, val: &Null); + #[wasm_bindgen(method, getter)] + pub fn reason(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_reason(this: &FlagshipEvaluationDetails, val: &str); + #[wasm_bindgen(method, setter, js_name = "reason")] + pub fn set_reason_with_null(this: &FlagshipEvaluationDetails, val: &Null); + #[wasm_bindgen(method, getter, js_name = "errorCode")] + pub fn error_code(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter, js_name = "errorCode")] + pub fn set_error_code(this: &FlagshipEvaluationDetails, val: &str); + #[wasm_bindgen(method, setter, js_name = "errorCode")] + pub fn set_error_code_with_null(this: &FlagshipEvaluationDetails, val: &Null); + #[wasm_bindgen(method, getter, js_name = "errorMessage")] + pub fn error_message(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter, js_name = "errorMessage")] + pub fn set_error_message(this: &FlagshipEvaluationDetails, val: &str); + #[wasm_bindgen(method, setter, js_name = "errorMessage")] + pub fn set_error_message_with_null(this: &FlagshipEvaluationDetails, val: &Null); +} +impl FlagshipEvaluationDetails { + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flag_key`"] + #[doc = " * `value`"] + pub fn new(flag_key: &str, value: &T) -> FlagshipEvaluationDetails { + Self::builder(flag_key, value).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flag_key`"] + #[doc = " * `value`"] + pub fn builder(flag_key: &str, value: &T) -> FlagshipEvaluationDetailsBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_flag_key(flag_key); + inner.set_value(value); + FlagshipEvaluationDetailsBuilder { inner } + } +} +pub struct FlagshipEvaluationDetailsBuilder { + inner: FlagshipEvaluationDetails, +} +impl FlagshipEvaluationDetailsBuilder { + pub fn variant(self, val: &str) -> Self { + self.inner.set_variant(val); + self + } + pub fn variant_with_null(self, val: &Null) -> Self { + self.inner.set_variant_with_null(val); + self + } + pub fn reason(self, val: &str) -> Self { + self.inner.set_reason(val); + self + } + pub fn reason_with_null(self, val: &Null) -> Self { + self.inner.set_reason_with_null(val); + self + } + pub fn error_code(self, val: &str) -> Self { + self.inner.set_error_code(val); + self + } + pub fn error_code_with_null(self, val: &Null) -> Self { + self.inner.set_error_code_with_null(val); + self + } + pub fn error_message(self, val: &str) -> Self { + self.inner.set_error_message(val); + self + } + pub fn error_message_with_null(self, val: &Null) -> Self { + self.inner.set_error_message_with_null(val); + self + } + pub fn build(self) -> FlagshipEvaluationDetails { + self.inner + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type Flagship; + #[doc = " Get a flag value without type checking."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Optional default value returned when evaluation fails."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch)] + pub async fn get(this: &Flagship, flag_key: &str) -> Result; + #[doc = " Get a flag value without type checking."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Optional default value returned when evaluation fails."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "get")] + pub async fn get_with_default_value( + this: &Flagship, + flag_key: &str, + default_value: &JsValue, + ) -> Result; + #[doc = " Get a flag value without type checking."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Optional default value returned when evaluation fails."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "get")] + pub async fn get_with_default_value_and_context( + this: &Flagship, + flag_key: &str, + default_value: &JsValue, + context: &Object, + ) -> Result; + #[doc = " Get a boolean flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getBooleanValue")] + pub async fn get_boolean_value( + this: &Flagship, + flag_key: &str, + default_value: bool, + ) -> Result; + #[doc = " Get a boolean flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getBooleanValue")] + pub async fn get_boolean_value_with_context( + this: &Flagship, + flag_key: &str, + default_value: bool, + context: &Object, + ) -> Result; + #[doc = " Get a string flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value( + this: &Flagship, + flag_key: &str, + default_value: &str, + ) -> Result; + #[doc = " Get a string flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value_with_context( + this: &Flagship, + flag_key: &str, + default_value: &str, + context: &Object, + ) -> Result; + #[doc = " Get a number flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getNumberValue")] + pub async fn get_number_value( + this: &Flagship, + flag_key: &str, + default_value: f64, + ) -> Result; + #[doc = " Get a number flag value."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getNumberValue")] + pub async fn get_number_value_with_context( + this: &Flagship, + flag_key: &str, + default_value: f64, + context: &Object, + ) -> Result; + #[doc = " Get a boolean flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getBooleanDetails")] + pub async fn get_boolean_details( + this: &Flagship, + flag_key: &str, + default_value: bool, + ) -> Result; + #[doc = " Get a boolean flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getBooleanDetails")] + pub async fn get_boolean_details_with_context( + this: &Flagship, + flag_key: &str, + default_value: bool, + context: &Object, + ) -> Result; + #[doc = " Get a string flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details( + this: &Flagship, + flag_key: &str, + default_value: &str, + ) -> Result; + #[doc = " Get a string flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details_with_context( + this: &Flagship, + flag_key: &str, + default_value: &str, + context: &Object, + ) -> Result; + #[doc = " Get a number flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getNumberDetails")] + pub async fn get_number_details( + this: &Flagship, + flag_key: &str, + default_value: f64, + ) -> Result; + #[doc = " Get a number flag value with full evaluation details."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `flagKey` - The key of the flag to evaluate."] + #[doc = " * `defaultValue` - Default value returned when evaluation fails or the flag type does not match."] + #[doc = " * `context` - Optional evaluation context for targeting rules."] + #[wasm_bindgen(method, catch, js_name = "getNumberDetails")] + pub async fn get_number_details_with_context( + this: &Flagship, + flag_key: &str, + default_value: f64, + context: &Object, + ) -> Result; +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index c54129ca..7bb98f67 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -177,6 +177,7 @@ pub use crate::dynamic_dispatch::*; pub use crate::env::{Env, EnvBinding, Secret, Var}; pub use crate::error::Error; pub use crate::fetcher::Fetcher; +pub use crate::flagship::*; pub use crate::formdata::*; pub use crate::global::Fetch; pub use crate::headers::Headers; @@ -235,6 +236,18 @@ mod email; mod env; mod error; mod fetcher; +mod flagship; +// Generated by `chomp build:types:flagship` from types/flagship.d.ts. Same +// allow-list as `email`: ts-gen owns the file, so suppress the lints it +// can't avoid emitting. +#[allow( + unused_imports, + dead_code, + missing_debug_implementations, + clippy::module_inception, + clippy::new_without_default +)] +mod flagship_gen; mod formdata; mod global; mod headers;