diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 3c94782b2..e146dd0d8 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -66,6 +66,11 @@ jobs: - name: Install cargo-generate run: cargo binstall --locked cargo-generate + - name: Build wasm-bindgen + run: | + cd wasm-bindgen + cargo build -p wasm-bindgen-cli --bin wasm-bindgen + - name: Generate and build all templates run: | mkdir -p generated @@ -79,7 +84,7 @@ jobs: cargo generate --path $template --name "$template_name" --destination ./generated --force --define panic_unwind=false cd "generated/$template_name" echo "Building $template_name" - ../../target/debug/worker-build + WASM_BINDGEN_BIN='../../wasm-bindgen/target/debug/wasm-bindgen' ../../target/debug/worker-build cd ../.. done @@ -109,7 +114,9 @@ jobs: run: chmod +x ./target/debug/worker-build - name: Build wasm-bindgen - run: cd wasm-bindgen && cargo build -p wasm-bindgen-cli --bin wasm-bindgen + run: | + cd wasm-bindgen + cargo build -p wasm-bindgen-cli --bin wasm-bindgen - name: Build all examples run: | diff --git a/Cargo.lock b/Cargo.lock index cb8b17987..5b115a557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -205,7 +205,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -456,7 +456,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -705,7 +705,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -768,7 +768,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -839,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -885,6 +885,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" @@ -971,7 +980,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1414,7 +1423,7 @@ checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1429,9 +1438,7 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +version = "0.3.104" dependencies = [ "cfg-if", "futures-util", @@ -1645,7 +1652,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1741,7 +1748,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1885,7 +1892,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1976,7 +1983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2219,7 +2226,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2394,7 +2401,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2599,7 +2606,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2619,6 +2626,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2633,7 +2651,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2663,7 +2681,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2701,7 +2719,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2712,7 +2730,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2805,7 +2823,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3031,7 +3049,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3347,9 +3365,9 @@ dependencies = [ [[package]] name = "walrus" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bfa49767bb3a9e1afb02aa95bbcbde8d82f2db4ca377afae94d688f14f62378" +checksum = "25b63a2bc6e4acb4cf69080068a531bad4530791098230e972ffcb64fd1dc266" dependencies = [ "anyhow", "gimli", @@ -3371,7 +3389,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3418,9 +3436,7 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +version = "0.2.127" dependencies = [ "cfg-if", "once_cell", @@ -3431,9 +3447,7 @@ dependencies = [ [[package]] name = "wasm-bindgen-cli-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8106e743adc30550ed6b44d50442a0929efbaed693032b6c71f03ac0b0e860fe" +version = "0.2.127" dependencies = [ "anyhow", "base64", @@ -3449,9 +3463,7 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +version = "0.4.77" dependencies = [ "js-sys", "wasm-bindgen", @@ -3459,9 +3471,7 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +version = "0.2.127" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3469,31 +3479,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +version = "0.2.127" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.4", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +version = "0.2.127" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16931d57bcdd6a0dcc11254bdd1388827690c1b6807f0d2836f59a4f9eb30ac" +version = "0.3.77" dependencies = [ "async-trait", "cast", @@ -3513,20 +3517,16 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fccaddcb2cd722baa7453b4c3d2cb2a3071597bb91f7a4373e8775bdb151778c" +version = "0.3.77" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3746ad029217960123cf2ebf939dda51eb931ba9f8f09c117e39c820b96aa794" +version = "0.2.127" [[package]] name = "wasm-encoder" @@ -3611,9 +3611,7 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +version = "0.3.104" dependencies = [ "js-sys", "wasm-bindgen", @@ -3690,7 +3688,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3720,7 +3718,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3731,7 +3729,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3966,7 +3964,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3982,7 +3980,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4116,7 +4114,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen", "wasm-bindgen-macro-support", "wit-parser 0.205.0", @@ -4130,7 +4128,7 @@ dependencies = [ "proc-macro2", "quote", "strum", - "syn", + "syn 2.0.117", "trybuild", "wasm-bindgen", "wasm-bindgen-macro-support", @@ -4234,7 +4232,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4255,7 +4253,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4275,7 +4273,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4296,7 +4294,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4329,7 +4327,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6241b485f..55ebe8f08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ chrono = { version = "0.4.41", default-features = false, features = [ futures-channel = "0.3.31" futures-util = { version = "0.3.31", default-features = false } http = "1.3" -js-sys = { version = "0.3.102" } +js-sys = { version = "0.3.104" } serde = { version = "1.0.164", features = ["derive"] } strum = { version = "0.27", features = ["derive"] } serde_json = "1.0.140" @@ -37,14 +37,14 @@ syn = "2.0.17" trybuild = "1.0" proc-macro2 = "1.0.60" quote = "1.0.28" -wasm-bindgen = { version = "0.2.125" } -wasm-bindgen-cli-support = { version = "0.2.125" } -wasm-bindgen-futures = { version = "0.4.75" } -wasm-bindgen-macro-support = { version = "0.2.125" } -wasm-bindgen-shared = { version = "0.2.125" } -wasm-bindgen-test = { version = "0.3.75" } +wasm-bindgen = { version = "0.2.127" } +wasm-bindgen-cli-support = { version = "0.2.127" } +wasm-bindgen-futures = { version = "0.4.77" } +wasm-bindgen-macro-support = { version = "0.2.127" } +wasm-bindgen-shared = { version = "0.2.127" } +wasm-bindgen-test = { version = "0.3.77" } wasm-streams = { version = "0.6.0" } -web-sys = { version = "0.3.102", features = [ +web-sys = { version = "0.3.104", features = [ "AbortController", "AbortSignal", "BinaryType", @@ -105,3 +105,15 @@ opt-level = "z" # These are local patches we use to test against local wasm bindgen # We always align on the exact stable wasm bindgen version for releases +[patch.crates-io] +js-sys = { path = "wasm-bindgen/crates/js-sys" } +wasm-bindgen = { path = "wasm-bindgen" } +wasm-bindgen-cli-support = { path = "wasm-bindgen/crates/cli-support" } +wasm-bindgen-futures = { path = "wasm-bindgen/crates/futures" } +wasm-bindgen-macro = { path = "wasm-bindgen/crates/macro" } +wasm-bindgen-macro-support = { path = "wasm-bindgen/crates/macro-support" } +wasm-bindgen-shared = { path = "wasm-bindgen/crates/shared" } +wasm-bindgen-test = { path = "wasm-bindgen/crates/test" } +wasm-bindgen-test-macro = { path = "wasm-bindgen/crates/test-macro" } +wasm-bindgen-test-shared = { path = "wasm-bindgen/crates/test-shared" } +web-sys = { path = "wasm-bindgen/crates/web-sys" } diff --git a/chompfile.toml b/chompfile.toml index 036964a85..0b32785a3 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`, …) @@ -19,6 +23,12 @@ run = '''ts-gen --input types/email.d.ts --output worker/src/bindings/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/bindings/flagship.rs \ + --experimental-generic-mono''' + [[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 000000000..03fca2b68 --- /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 000000000..dde354b6c --- /dev/null +++ b/examples/flagship/src/lib.rs @@ -0,0 +1,91 @@ +//! 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, RouteContext, 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", boolean) + .get_async("/string", string) + .get_async("/object", object) + .get_async("/details", details) + .run(req, env) + .await +} + +async fn boolean(req: Request, ctx: RouteContext<()>) -> Result { + let env = ctx.env; + 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.as_str(), false) + .await?; + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +async fn string(req: Request, ctx: RouteContext<()>) -> Result { + let env = ctx.env; + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "checkout-flow".into()); + let flagship = env.flagship(BINDING)?; + let value: String = 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.as_str(), "control", ctx.as_ref()) + .await? + } + None => flagship.get_string_value(flag.as_str(), "control").await?, + }; + Response::from_json(&serde_json::json!({ "flag": flag, "value": value })) +} + +async fn object(req: Request, ctx: RouteContext<()>) -> Result { + let env = ctx.env; + 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, ctx: RouteContext<()>) -> Result { + let env = ctx.env; + let url = req.url()?; + let flag = query(&url, "flag").unwrap_or_else(|| "checkout-flow".into()); + let details = env + .flagship(BINDING)? + .get_string_details(flag.as_str(), "control") + .await?; + Response::from_json(&details) +} + +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 000000000..0a108fea5 --- /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 000000000..13d64c654 --- /dev/null +++ b/test/src/flagship.rs @@ -0,0 +1,149 @@ +use crate::SomeSharedData; +use serde::{Deserialize, Serialize}; +use worker::wasm_bindgen::JsValue; +use worker::{Env, EvaluationContext, EvaluationDetails, 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.as_str(), false) + .await?; + 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 = env + .flagship(BINDING)? + .get_string_value(flag.as_str(), "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.as_str(), 0.0) + .await?; + 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.as_str(), &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 = 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.as_str(), false) + .await?; + Response::from_json(&details) +} + +#[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.as_str(), "fallback") + .await?; + Response::from_json(&details) +} + +#[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.as_str(), 0.0) + .await?; + Response::from_json(&details) +} + +#[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) +} diff --git a/test/src/lib.rs b/test/src/lib.rs index 37f718fd0..090921b0e 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 b5a4b56b2..1b988592c 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; @@ -243,6 +243,16 @@ macro_rules! add_routes ( add_route!($obj, get, "/rate-limit/reset", rate_limit::handle_rate_limit_reset); add_route!($obj, get, "/send-email", send_email::handle_send_email); add_route!($obj, get, "/signal/poll", signal::handle_signal_poll); + 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); }); #[cfg(feature = "http")] diff --git a/test/tests/flagship.spec.ts b/test/tests/flagship.spec.ts new file mode 100644 index 000000000..96a6f2df8 --- /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 8d8003e93..e8af6ac65 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 b698be710..188f4be06 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit b698be710dd2f96e813796d9bac6ebb089c90f0f +Subproject commit 188f4be06179d6114d334a135c1f09a425983b64 diff --git a/types/flagship.d.ts b/types/flagship.d.ts new file mode 100644 index 000000000..e1c5cdef2 --- /dev/null +++ b/types/flagship.d.ts @@ -0,0 +1,106 @@ +/* + * 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/bindings/flagship.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; + getStringValue( + flagKey: string, + defaultValue: string, + context?: FlagshipEvaluationContext, + ): Promise; + getNumberValue( + flagKey: string, + defaultValue: number, + context?: FlagshipEvaluationContext, + ): Promise; + // EDIT: `getObjectValue(...)` is hand-written in + // worker/src/flagship.rs to deserialize arbitrary Rust object types. + /** + * 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>; + getStringDetails( + flagKey: string, + defaultValue: string, + context?: FlagshipEvaluationContext, + ): Promise>; + getNumberDetails( + flagKey: string, + defaultValue: number, + context?: FlagshipEvaluationContext, + ): Promise>; + // EDIT: `getObjectDetails(...)` is hand-written in + // worker/src/flagship.rs to return serde-backed `EvaluationDetails`. +} diff --git a/wasm-bindgen b/wasm-bindgen index 6ff7f1cd2..065f70950 160000 --- a/wasm-bindgen +++ b/wasm-bindgen @@ -1 +1 @@ -Subproject commit 6ff7f1cd2f636d6f19d29b45680885088cc9e653 +Subproject commit 065f709500fa117ba120c03bf143c66ea73cd07d diff --git a/worker-build/README.md b/worker-build/README.md index cb39a57fa..a50036092 100644 --- a/worker-build/README.md +++ b/worker-build/README.md @@ -33,4 +33,14 @@ You can override the default binary lookup/download behavior by setting these en export WASM_BINDGEN_BIN=/path/to/custom/wasm-bindgen export WASM_OPT_BIN=/path/to/custom/wasm-opt worker-build --release -``` \ No newline at end of file +``` + +When developing against the pinned wasm-bindgen submodule, build its CLI and +point worker-build at that exact binary so the CLI metadata schema matches the +patched Rust crates: + +```bash +cargo +stable build --manifest-path wasm-bindgen/Cargo.toml -p wasm-bindgen-cli --bin wasm-bindgen +export WASM_BINDGEN_BIN="$PWD/wasm-bindgen/target/debug/wasm-bindgen" +worker-build --release +``` diff --git a/worker-build/src/versions.rs b/worker-build/src/versions.rs index 5a50b8fb5..d63b4f30a 100644 --- a/worker-build/src/versions.rs +++ b/worker-build/src/versions.rs @@ -7,7 +7,7 @@ macro_rules! version { } // Current build toolchain, always used exactly for builds, unless overridden by {}_BIN env vars -pub(crate) static LATEST_WASM_BINDGEN_VERSION: LazyLock = version!("0.2.125"); +pub(crate) static LATEST_WASM_BINDGEN_VERSION: LazyLock = version!("0.2.127"); pub(crate) static CUR_WASM_OPT_VERSION: &str = "130"; pub(crate) static CUR_ESBUILD_VERSION: LazyLock = version!("0.28.1"); diff --git a/worker/src/bindings/flagship.rs b/worker/src/bindings/flagship.rs new file mode 100644 index 000000000..3dbb6ff3b --- /dev/null +++ b/worker/src/bindings/flagship.rs @@ -0,0 +1,291 @@ +// Generated by ts-gen. Do not edit. + +#[allow(unused_imports)] +use js_sys::*; +#[allow(unused_imports)] +use wasm_bindgen::prelude::*; +#[allow(dead_code)] +pub type FlagshipEvaluationContext = Object; +#[wasm_bindgen(experimental_generic_mono)] +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, getter, js_name = "flagKey")] + pub fn flag_key_js_string(this: &FlagshipEvaluationDetails) -> JsString; + #[wasm_bindgen(method, setter, js_name = "flagKey")] + pub fn set_flag_key( + this: &FlagshipEvaluationDetails, + val: S, + ); + #[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, getter, js_name = "variant")] + pub fn variant_js_string(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_variant( + this: &FlagshipEvaluationDetails, + val: S, + ); + #[wasm_bindgen(method, getter)] + pub fn reason(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, getter, js_name = "reason")] + pub fn reason_js_string(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_reason( + this: &FlagshipEvaluationDetails, + val: S, + ); + #[wasm_bindgen(method, getter, js_name = "errorCode")] + pub fn error_code(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, getter, js_name = "errorCode")] + pub fn error_code_js_string(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter, js_name = "errorCode")] + pub fn set_error_code( + this: &FlagshipEvaluationDetails, + val: S, + ); + #[wasm_bindgen(method, getter, js_name = "errorMessage")] + pub fn error_message(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, getter, js_name = "errorMessage")] + pub fn error_message_js_string(this: &FlagshipEvaluationDetails) -> Option; + #[wasm_bindgen(method, setter, js_name = "errorMessage")] + pub fn set_error_message( + this: &FlagshipEvaluationDetails, + val: S, + ); +} +impl FlagshipEvaluationDetails { + pub fn new( + flag_key: S, + value: T, + ) -> FlagshipEvaluationDetails { + Self::builder(flag_key, value).build() + } + pub fn builder( + flag_key: S, + value: T, + ) -> FlagshipEvaluationDetailsBuilder { + let inner: FlagshipEvaluationDetails = 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: S) -> Self { + self.inner.set_variant(val); + self + } + pub fn reason(self, val: S) -> Self { + self.inner.set_reason(val); + self + } + pub fn error_code(self, val: S) -> Self { + self.inner.set_error_code(val); + self + } + pub fn error_message(self, val: S) -> Self { + self.inner.set_error_message(val); + self + } + pub fn build(self) -> FlagshipEvaluationDetails { + self.inner + } +} +#[wasm_bindgen(experimental_generic_mono)] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type Flagship; + #[doc = " Get a flag value without type checking."] + #[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: S, + ) -> Result; + #[doc = " Get a flag value without type checking."] + #[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: S, + default_value: &JsValue, + ) -> Result; + #[doc = " Get a flag value without type checking."] + #[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: S, + default_value: &JsValue, + context: &Object, + ) -> Result; + #[doc = " Get a boolean flag value."] + #[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: S, + default_value: bool, + ) -> Result; + #[doc = " Get a boolean flag value."] + #[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: S, + default_value: bool, + context: &Object, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value_js_string< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value_with_context< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + context: &Object, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getStringValue")] + pub async fn get_string_value_with_context_js_string< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + context: &Object, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getNumberValue")] + pub async fn get_number_value( + this: &Flagship, + flag_key: S, + default_value: f64, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "getNumberValue")] + pub async fn get_number_value_with_context( + this: &Flagship, + flag_key: S, + default_value: f64, + context: &Object, + ) -> Result; + #[doc = " Get a boolean flag value with full evaluation details."] + #[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: S, + default_value: bool, + ) -> Result, JsValue>; + #[doc = " Get a boolean flag value with full evaluation details."] + #[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: S, + default_value: bool, + context: &Object, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details_js_string< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details_with_context< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + context: &Object, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getStringDetails")] + pub async fn get_string_details_with_context_js_string< + S: ::wasm_bindgen::JsStringLike, + S2: ::wasm_bindgen::JsStringLike, + >( + this: &Flagship, + flag_key: S, + default_value: S2, + context: &Object, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getNumberDetails")] + pub async fn get_number_details( + this: &Flagship, + flag_key: S, + default_value: f64, + ) -> Result, JsValue>; + #[wasm_bindgen(method, catch, js_name = "getNumberDetails")] + pub async fn get_number_details_with_context( + this: &Flagship, + flag_key: S, + default_value: f64, + context: &Object, + ) -> Result, JsValue>; +} diff --git a/worker/src/bindings/mod.rs b/worker/src/bindings/mod.rs index aa5f45d47..263736b86 100644 --- a/worker/src/bindings/mod.rs +++ b/worker/src/bindings/mod.rs @@ -1 +1,2 @@ pub mod email; +pub mod flagship; diff --git a/worker/src/env.rs b/worker/src/env.rs index 61d02c879..cb6828012 100644 --- a/worker/src/env.rs +++ b/worker/src/env.rs @@ -4,6 +4,7 @@ use crate::analytics_engine::AnalyticsEngineDataset; #[cfg(feature = "d1")] use crate::d1::D1Database; use crate::email::SendEmail; +use crate::flagship::Flagship; use crate::kv::KvStore; use crate::rate_limit::RateLimiter; use crate::Ai; @@ -138,6 +139,12 @@ impl Env { pub fn send_email(&self, binding: &str) -> Result { 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) + } } pub trait EnvBinding: Sized + JsCast { diff --git a/worker/src/flagship.rs b/worker/src/flagship.rs new file mode 100644 index 000000000..fd9157fec --- /dev/null +++ b/worker/src/flagship.rs @@ -0,0 +1,241 @@ +use crate::{send::SendFuture, EnvBinding, Result}; +use js_sys::{Object, Promise}; +use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize, Serializer}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +// Hand-written companion to the ts-gen output in `bindings/flagship.rs`. +// Object values still need serde conversion because arbitrary Rust structs are +// not wasm-bindgen ABI types. `EvaluationContext` and `EnvBinding` also remain +// here because ts-gen does not synthesize them. +pub use crate::bindings::flagship::{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 methods hand-rolled off raw `Promise` externs. +#[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 from_js(promise: Promise) -> Result { + let raw = SendFuture::new(JsFuture::from(promise)).await?; + Ok(serde_wasm_bindgen::from_value(raw)?) +} + +async fn call_object( + default_value: &I, + promise_fn: impl FnOnce(&JsValue) -> Promise, +) -> Result { + let default = serde_wasm_bindgen::to_value(default_value)?; + from_js(promise_fn(&default)).await +} + +/// Typed evaluation record returned by the object details methods. Primitive +/// flags use the auto-generated [`FlagshipEvaluationDetails`] 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, +} + +impl Serialize for FlagshipEvaluationDetails +where + T: Serialize + wasm_bindgen::convert::FromWasmAbi, +{ + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + EvaluationDetails { + flag_key: self.flag_key(), + value: self.value(), + variant: self.variant(), + reason: self.reason(), + error_code: self.error_code(), + error_message: self.error_message(), + } + .serialize(serializer) + } +} + +impl<'de, T> Deserialize<'de> for FlagshipEvaluationDetails +where + T: Deserialize<'de> + wasm_bindgen::convert::IntoWasmAbi, +{ + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let EvaluationDetails { + flag_key, + value, + variant, + reason, + error_code, + error_message, + } = EvaluationDetails::deserialize(deserializer)?; + + let mut builder = Self::builder(flag_key, value); + if let Some(variant) = variant.as_deref() { + builder = builder.variant(variant); + } + if let Some(reason) = reason.as_deref() { + builder = builder.reason(reason); + } + if let Some(error_code) = error_code.as_deref() { + builder = builder.error_code(error_code); + } + if let Some(error_message) = error_message.as_deref() { + builder = builder.error_message(error_message); + } + Ok(builder.build()) + } +} + +/// 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/lib.rs b/worker/src/lib.rs index 604cf0c09..6ce1fbabf 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -178,6 +178,7 @@ pub use crate::email::*; 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,7 @@ pub mod email; mod env; mod error; mod fetcher; +mod flagship; mod formdata; mod global; mod headers;