From 45c284d43f051c4d76a10841d5212581bd796009 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Tue, 27 Jan 2026 18:14:18 -0600 Subject: [PATCH 01/21] feat: add Workflows support Add bindings for Workflows including step primitives (do, sleep, sleepUntil), event/instance management, and a proc macro for defining workflow entrypoints. Includes example worker, sys-level bindings, and integration tests. --- Cargo.lock | 9 + Cargo.toml | 2 +- examples/workflow/Cargo.toml | 15 + examples/workflow/src/lib.rs | 154 +++++++++ examples/workflow/wrangler.toml | 13 + rust-toolchain.toml | 2 +- test/src/lib.rs | 1 + test/src/router.rs | 4 +- test/src/workflow.rs | 74 +++++ test/tests/mf.ts | 21 ++ test/tests/workflow.spec.ts | 37 +++ test/wrangler.toml | 5 + worker-build/src/main.rs | 100 ++++-- worker-macros/Cargo.toml | 1 + worker-macros/src/lib.rs | 35 ++ worker-macros/src/workflow.rs | 79 +++++ worker-sys/Cargo.toml | 1 + worker-sys/src/types.rs | 4 + worker-sys/src/types/workflow.rs | 87 +++++ worker/Cargo.toml | 1 + worker/src/env.rs | 8 + worker/src/lib.rs | 8 + worker/src/workflow.rs | 526 +++++++++++++++++++++++++++++++ 23 files changed, 1155 insertions(+), 32 deletions(-) create mode 100644 examples/workflow/Cargo.toml create mode 100644 examples/workflow/src/lib.rs create mode 100644 examples/workflow/wrangler.toml create mode 100644 test/src/workflow.rs create mode 100644 test/tests/workflow.spec.ts create mode 100644 worker-macros/src/workflow.rs create mode 100644 worker-sys/src/types/workflow.rs create mode 100644 worker/src/workflow.rs diff --git a/Cargo.lock b/Cargo.lock index e4372f752..45cfd40ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3736,6 +3736,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "workflow-example" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "worker 0.7.4", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 4763782ef..0c458d12d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ web-sys = { version = "0.3.85", features = [ "WritableStream", "WritableStreamDefaultWriter", ] } -worker = { version = "0.7.4", path = "worker", features = ["queue", "d1", "axum", "timezone"] } +worker = { version = "0.7.4", path = "worker", features = ["queue", "d1", "axum", "timezone", "workflow"] } worker-codegen = { path = "worker-codegen", version = "0.2.0" } worker-macros = { version = "0.7.4", path = "worker-macros", features = ["queue"] } worker-sys = { version = "0.7.4", path = "worker-sys", features = ["d1", "queue"] } diff --git a/examples/workflow/Cargo.toml b/examples/workflow/Cargo.toml new file mode 100644 index 000000000..41e4881b3 --- /dev/null +++ b/examples/workflow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "workflow-example" +version = "0.1.0" +edition = "2021" + +[package.metadata.release] +release = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +worker = { path = "../../worker", features = ["workflow"] } diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs new file mode 100644 index 000000000..82a9e22c6 --- /dev/null +++ b/examples/workflow/src/lib.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use worker::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyParams { + pub email: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyOutput { + pub message: String, + pub steps_completed: u32, +} + +#[workflow] +pub struct MyWorkflow { + #[allow(dead_code)] + env: Env, +} + +impl WorkflowEntrypoint for MyWorkflow { + fn new(_ctx: Context, env: Env) -> Self { + Self { env } + } + + async fn run( + &self, + event: WorkflowEvent, + step: WorkflowStep, + ) -> Result { + console_log!("Workflow started with instance ID: {}", event.instance_id); + + let params: MyParams = + serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + + let name_for_step1 = params.name.clone(); + let step1_result = step + .do_("initial-processing", move || async move { + console_log!("Processing for user: {}", name_for_step1); + Ok(serde_json::json!({ + "processed": true, + "user": name_for_step1 + })) + }) + .await?; + + console_log!("Step 1 completed: {:?}", step1_result); + + console_log!("Step 2: Sleeping for 10 seconds..."); + step.sleep("wait-for-processing", "10 seconds").await?; + + let email_for_step3 = params.email.clone(); + let notification_result = step + .do_with_config( + "send-notification", + StepConfig { + retries: Some(RetryConfig { + limit: 3, + delay: "5 seconds".to_string(), + backoff: Some(Backoff::Exponential), + }), + timeout: Some("1 minute".to_string()), + }, + move || async move { + console_log!("Sending notification to: {}", email_for_step3); + Ok(serde_json::json!({ + "notification_sent": true, + "email": email_for_step3 + })) + }, + ) + .await?; + + console_log!("Step 3 completed: {:?}", notification_result); + + let output = MyOutput { + message: format!("Workflow completed for {}", params.name), + steps_completed: 3, + }; + + Ok(serde_json::to_value(output).unwrap()) + } +} + +#[event(fetch)] +async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { + let url = req.url()?; + let path = url.path(); + let workflow = env.workflow("MY_WORKFLOW")?; + + match (req.method(), path) { + (Method::Post, "/workflow") => { + let params = MyParams { + email: "user@example.com".to_string(), + name: "Test User".to_string(), + }; + + let instance = workflow + .create(Some(CreateOptions { + id: None, + params: Some(params), + retention: None, + })) + .await?; + + Response::from_json(&serde_json::json!({ + "id": instance.id()?, + "message": "Workflow created" + })) + } + + (Method::Get, path) if path.starts_with("/workflow/") => { + let id = path.trim_start_matches("/workflow/"); + let instance = workflow.get(id).await?; + let status = instance.status().await?; + + Response::from_json(&serde_json::json!({ + "id": instance.id()?, + "status": format!("{:?}", status.status), + "error": status.error, + "output": status.output + })) + } + + (Method::Post, path) if path.starts_with("/workflow/") && path.ends_with("/pause") => { + let id = path + .trim_start_matches("/workflow/") + .trim_end_matches("/pause"); + let instance = workflow.get(id).await?; + instance.pause().await?; + + Response::from_json(&serde_json::json!({ + "id": instance.id()?, + "message": "Workflow paused" + })) + } + + (Method::Post, path) if path.starts_with("/workflow/") && path.ends_with("/resume") => { + let id = path + .trim_start_matches("/workflow/") + .trim_end_matches("/resume"); + let instance = workflow.get(id).await?; + instance.resume().await?; + + Response::from_json(&serde_json::json!({ + "id": instance.id()?, + "message": "Workflow resumed" + })) + } + + _ => Response::error("Not Found", 404), + } +} diff --git a/examples/workflow/wrangler.toml b/examples/workflow/wrangler.toml new file mode 100644 index 000000000..b20e6ef7c --- /dev/null +++ b/examples/workflow/wrangler.toml @@ -0,0 +1,13 @@ +name = "workflow-example" +main = "build/worker/shim.mjs" +compatibility_date = "2024-10-22" + +[build] +# For development: use local worker-build binary +# For production: command = "cargo install -q worker-build && worker-build --release" +command = "RUSTFLAGS='--cfg=web_sys_unstable_apis' ../../target/release/worker-build --release" + +[[workflows]] +name = "my-workflow" +binding = "MY_WORKFLOW" +class_name = "MyWorkflow" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 291696d0e..ab40f4f44 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.87.0" +channel = "1.88.0" profile = "default" diff --git a/test/src/lib.rs b/test/src/lib.rs index 4f28ca705..d32e50a08 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -37,6 +37,7 @@ mod socket; mod sql_counter; mod sql_iterator; mod user; +mod workflow; mod ws; #[derive(Deserialize, Serialize)] diff --git a/test/src/router.rs b/test/src/router.rs index 4ca07033e..8dd936375 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -1,7 +1,7 @@ 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, service, socket, - sql_counter, sql_iterator, user, ws, SomeSharedData, GLOBAL_STATE, + sql_counter, sql_iterator, user, workflow, ws, SomeSharedData, GLOBAL_STATE, }; #[cfg(feature = "http")] use std::convert::TryInto; @@ -234,6 +234,8 @@ 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, post, "/workflow/create", workflow::handle_workflow_create); + add_route!($obj, get, format_route!("/workflow/status/{}", "id"), workflow::handle_workflow_status); }); #[cfg(feature = "http")] diff --git a/test/src/workflow.rs b/test/src/workflow.rs new file mode 100644 index 000000000..b1cc2b32c --- /dev/null +++ b/test/src/workflow.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use worker::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestParams { + pub value: String, +} + +#[workflow] +pub struct TestWorkflow { + #[allow(dead_code)] + env: Env, +} + +impl WorkflowEntrypoint for TestWorkflow { + fn new(_ctx: Context, env: Env) -> Self { + Self { env } + } + + async fn run( + &self, + event: WorkflowEvent, + step: WorkflowStep, + ) -> Result { + let params: TestParams = + serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + + let result = step + .do_("process", move || async move { + Ok(serde_json::json!({ "processed": params.value })) + }) + .await?; + + Ok(result) + } +} + +pub async fn handle_workflow_create( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let workflow = env.workflow("TEST_WORKFLOW")?; + let params = TestParams { + value: "hello".to_string(), + }; + let instance = workflow + .create(Some(CreateOptions { + params: Some(params), + ..Default::default() + })) + .await?; + + Response::from_json(&serde_json::json!({ "id": instance.id()? })) +} + +pub async fn handle_workflow_status( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let url = req.url()?; + let path = url.path(); + let id = path.trim_start_matches("/workflow/status/"); + let workflow = env.workflow("TEST_WORKFLOW")?; + let instance = workflow.get(id).await?; + let status = instance.status().await?; + + Response::from_json(&serde_json::json!({ + "status": format!("{:?}", status.status), + "output": status.output, + "error": status.error, + })) +} diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 7a63c7d39..26ba3afe7 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -37,6 +37,7 @@ const mf_instance = new Miniflare({ kvPersist: false, r2Persist: false, cachePersist: false, + workflowsPersist: false, workers: [ { scriptPath: "./build/index.js", @@ -113,6 +114,15 @@ const mf_instance = new Miniflare({ scriptName: "mini-analytics-engine" // mock out analytics engine binding to the "mini-analytics-engine" worker } }, + // Workflow binding requires a separate worker via scriptName in + // the Miniflare JS API (wrangler dev handles this automatically). + workflows: { + TEST_WORKFLOW: { + name: "test-workflow", + className: "TestWorkflow", + scriptName: "workflow-worker", + }, + }, ratelimits: { TEST_RATE_LIMITER: { simple: { @@ -122,6 +132,17 @@ const mf_instance = new Miniflare({ } } }, + { + // Dedicated worker for TestWorkflow; uses the generated JS class wrapper. + name: "workflow-worker", + scriptPath: "./build/worker/shim.mjs", + modules: true, + modulesRules: [ + { type: "ESModule", include: ["**/*.js"], fallthrough: true }, + { type: "CompiledWasm", include: ["**/*.wasm"], fallthrough: true }, + ], + compatibilityDate: "2025-07-24", + }, { name: "mini-analytics-engine", modules: true, diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts new file mode 100644 index 000000000..9e7cce89e --- /dev/null +++ b/test/tests/workflow.spec.ts @@ -0,0 +1,37 @@ +import { describe, test, expect } from "vitest"; +import { mf, mfUrl } from "./mf"; + +describe("workflow", () => { + test("create and poll status until completion", async () => { + const createResp = await mf.dispatchFetch(`${mfUrl}workflow/create`, { + method: "POST", + }); + expect(createResp.status).toBe(200); + const { id } = (await createResp.json()) as { id: string }; + expect(id).toBeDefined(); + expect(typeof id).toBe("string"); + + let status: string | undefined; + let output: unknown; + for (let i = 0; i < 30; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const statusResp = await mf.dispatchFetch( + `${mfUrl}workflow/status/${id}` + ); + expect(statusResp.status).toBe(200); + const body = (await statusResp.json()) as { + status: string; + output: unknown; + error: unknown; + }; + status = body.status; + output = body.output; + if (status === "Complete" || status === "Errored") { + break; + } + } + + expect(status).toBe("Complete"); + expect(output).toEqual({ processed: "hello" }); + }); +}); diff --git a/test/wrangler.toml b/test/wrangler.toml index 0729a2b90..85f141ab6 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -85,6 +85,11 @@ class_name = "EchoContainer" image = "./container-echo/Dockerfile" max_instances = 1 +[[workflows]] +name = "test-workflow" +binding = "TEST_WORKFLOW" +class_name = "TestWorkflow" + [[ratelimits]] name = "TEST_RATE_LIMITER" namespace_id = "1" diff --git a/worker-build/src/main.rs b/worker-build/src/main.rs index 597a463f1..80f222409 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -119,7 +119,7 @@ pub fn main() -> Result<()> { ); fs::write(output_path(&out_dir, "shim.js"), shim)?; - add_export_wrappers(&out_dir)?; + let has_workflows = add_export_wrappers(&out_dir)?; update_package_json(&out_dir)?; @@ -130,7 +130,9 @@ pub fn main() -> Result<()> { remove_unused_files(&out_dir)?; - create_wrapper_alias(&out_dir, false)?; + if !has_workflows { + create_wrapper_alias(&out_dir, false)?; + } } else { main_legacy::process(&out_dir)?; create_wrapper_alias(&out_dir, true)?; @@ -198,28 +200,83 @@ fn generate_handlers(out_dir: &Path) -> Result { static SYSTEM_FNS: &[&str] = &["__wbg_reset_state", "setPanicHook"]; -fn add_export_wrappers(out_dir: &Path) -> Result<()> { +/// Returns true if workflow classes were detected and a wrapper was generated +fn add_export_wrappers(out_dir: &Path) -> Result { let index_path = output_path(out_dir, "index.js"); let content = fs::read_to_string(&index_path)?; let mut class_names = Vec::new(); + let mut workflow_classes = Vec::new(); for line in content.lines() { if let Some(rest) = line.strip_prefix("export class ") { if let Some(brace_pos) = rest.find("{") { - let class_name = rest[..brace_pos].trim(); - class_names.push(class_name.to_string()); + class_names.push(rest[..brace_pos].trim().to_string()); + } + } else if let Some(rest) = line.strip_prefix("export function __wf_") { + if let Some(paren_pos) = rest.find('(') { + workflow_classes.push(rest[..paren_pos].trim().to_string()); + } + } else if let Some(rest) = line.strip_prefix("export { __wf_") { + if let Some(as_pos) = rest.find(" as ") { + workflow_classes.push(rest[..as_pos].trim().to_string()); } } } let shim_path = output_path(out_dir, "shim.js"); let mut output = fs::read_to_string(&shim_path)?; - for class_name in class_names { - output.push_str(&format!( - "export const {class_name} = new Proxy(exports.{class_name}, classProxyHooks);\n" - )); + + for class_name in &class_names { + if workflow_classes.contains(class_name) { + output.push_str(&format!( + "export const {class_name} = exports.{class_name};\n" + )); + } else { + output.push_str(&format!( + "export const {class_name} = new Proxy(exports.{class_name}, classProxyHooks);\n" + )); + } } fs::write(&shim_path, output)?; + + // Workflows need a JS wrapper that extends WorkflowEntrypoint from cloudflare:workers + let has_workflows = !workflow_classes.is_empty(); + if has_workflows { + generate_workflow_wrapper(out_dir, &workflow_classes)?; + } + + Ok(has_workflows) +} + +fn generate_workflow_wrapper(out_dir: &Path, workflow_classes: &[String]) -> Result<()> { + let mut wrapper = String::from( + r#"import { WorkflowEntrypoint } from "cloudflare:workers"; +import * as wasm from "../index.js"; +export * from "../index.js"; +export { default } from "../index.js"; + +"#, + ); + + for class_name in workflow_classes { + wrapper.push_str(&format!( + r#"export class {class_name} extends WorkflowEntrypoint {{ + constructor(ctx, env) {{ + super(ctx, env); + this.inner = new wasm.{class_name}(ctx, env); + }} + async run(event, step) {{ + return await this.inner.run(event, step); + }} +}} + +"# + )); + } + + fs::create_dir_all(output_path(out_dir, "worker"))?; + fs::write(output_path(out_dir, "worker/shim.mjs"), wrapper)?; + Ok(()) } @@ -289,29 +346,14 @@ fn wasm_coredump(out_dir: &Path) -> Result<()> { } fn create_wrapper_alias(out_dir: &Path, legacy: bool) -> Result<()> { - let msg = if !legacy { - "// Use index.js directly, this file provided for backwards compat -// with former shim.mjs only. -" - } else { - "" - }; - let path = if !legacy { - "../index.js" + if legacy { + let shim_content = + "export * from './worker/shim.mjs';\nexport { default } from './worker/shim.mjs';\n"; + fs::write(output_path(out_dir, "index.js"), shim_content)?; } else { - "./worker/shim.mjs" - }; - let shim_content = format!( - "{msg}export * from '{path}'; -export {{ default }} from '{path}'; -" - ); - - if !legacy { + let shim_content = "// Use index.js directly, this file provided for backwards compat\n// with former shim.mjs only.\nexport * from '../index.js';\nexport { default } from '../index.js';\n"; fs::create_dir_all(output_path(out_dir, "worker"))?; fs::write(output_path(out_dir, "worker/shim.mjs"), shim_content)?; - } else { - fs::write(output_path(out_dir, "index.js"), shim_content)?; } Ok(()) } diff --git a/worker-macros/Cargo.toml b/worker-macros/Cargo.toml index 4628974b4..668088fa9 100644 --- a/worker-macros/Cargo.toml +++ b/worker-macros/Cargo.toml @@ -24,3 +24,4 @@ quote.workspace = true [features] queue = [] http = [] +workflow = [] diff --git a/worker-macros/src/lib.rs b/worker-macros/src/lib.rs index c6f2cd376..ed3bb9ff9 100644 --- a/worker-macros/src/lib.rs +++ b/worker-macros/src/lib.rs @@ -1,6 +1,8 @@ mod durable_object; mod event; mod send; +#[cfg(feature = "workflow")] +mod workflow; use proc_macro::TokenStream; @@ -141,3 +143,36 @@ pub fn send(attr: TokenStream, stream: TokenStream) -> TokenStream { pub fn consume(_: TokenStream, _: TokenStream) -> TokenStream { TokenStream::new() } + +/// Integrate the struct with the Workers Runtime as a Workflow Entrypoint. +/// Requires the `WorkflowEntrypoint` trait with the workflow attribute macro on the struct. +/// +/// ## Example +/// +/// ```rust,ignore +/// #[workflow] +/// pub struct MyWorkflow { +/// env: Env, +/// } +/// +/// impl WorkflowEntrypoint for MyWorkflow { +/// fn new(ctx: Context, env: Env) -> Self { +/// Self { env } +/// } +/// +/// async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { +/// let result = step.do_("my step", || async { +/// Ok(serde_json::json!({"data": "value"})) +/// }).await?; +/// +/// Ok(result) +/// } +/// } +/// ``` +#[cfg(feature = "workflow")] +#[proc_macro_attribute] +pub fn workflow(_attr: TokenStream, item: TokenStream) -> TokenStream { + workflow::expand_macro(item.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/worker-macros/src/workflow.rs b/worker-macros/src/workflow.rs new file mode 100644 index 000000000..45fbea7ef --- /dev/null +++ b/worker-macros/src/workflow.rs @@ -0,0 +1,79 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Error, ItemImpl, ItemStruct}; + +pub fn expand_macro(tokens: TokenStream) -> syn::Result { + if syn::parse2::(tokens.clone()).is_ok() { + return Err(Error::new( + proc_macro2::Span::call_site(), + "#[workflow] should only be applied to struct definitions, not impl blocks", + )); + } + + let target = syn::parse2::(tokens)?; + let target_name = &target.ident; + let marker_fn_name = format_ident!("__wf_{}", target_name); + let marker_js_name = format!("__wf_{}", target_name); + let target_name_str = target_name.to_string(); + + Ok(quote! { + #target + + impl ::worker::HasWorkflowAttribute for #target_name {} + + const _: () = { + use ::worker::wasm_bindgen::prelude::*; + #[allow(unused_imports)] + use ::worker::WorkflowEntrypoint; + + #[allow(non_snake_case)] + #[wasm_bindgen(js_name = #marker_js_name, wasm_bindgen=::worker::wasm_bindgen)] + pub fn #marker_fn_name() -> ::worker::js_sys::JsString { + ::worker::js_sys::JsString::from(#target_name_str) + } + + #[wasm_bindgen(wasm_bindgen=::worker::wasm_bindgen)] + #[::worker::consume] + #target + + #[wasm_bindgen(wasm_bindgen=::worker::wasm_bindgen)] + impl #target_name { + #[wasm_bindgen(constructor, wasm_bindgen=::worker::wasm_bindgen)] + pub fn new( + ctx: ::worker::worker_sys::Context, + env: ::worker::Env + ) -> Self { + ::new( + ::worker::Context::new(ctx), + env + ) + } + + #[wasm_bindgen(js_name = run, wasm_bindgen=::worker::wasm_bindgen)] + pub fn run( + &self, + event: ::worker::wasm_bindgen::JsValue, + step: ::worker::worker_sys::WorkflowStep + ) -> ::worker::js_sys::Promise { + // SAFETY: The Cloudflare Workers runtime manages the Workflow instance + // lifecycle. The runtime guarantees that: + // 1. The instance is created before run() is called + // 2. The instance is not destroyed while any Promise returned by run() is pending + // 3. WASM execution is single-threaded, so no concurrent access is possible + // This is the same lifecycle model used by Durable Objects and WorkerEntrypoint. + let static_self: &'static Self = unsafe { &*(self as *const _) }; + + ::worker::wasm_bindgen_futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let event = ::worker::WorkflowEvent::from_js(event) + .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string()))?; + let step = ::worker::WorkflowStep::from(step); + let result = ::run(static_self, event, step).await + .map_err(::worker::wasm_bindgen::JsValue::from)?; + ::worker::serialize_as_object(&result) + .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string())) + })) + } + } + }; + }) +} diff --git a/worker-sys/Cargo.toml b/worker-sys/Cargo.toml index 5ce54720e..25763204c 100644 --- a/worker-sys/Cargo.toml +++ b/worker-sys/Cargo.toml @@ -16,3 +16,4 @@ web-sys.workspace = true [features] d1 = [] queue = [] +workflow = [] diff --git a/worker-sys/src/types.rs b/worker-sys/src/types.rs index d6e63d4db..f874a4ea6 100644 --- a/worker-sys/src/types.rs +++ b/worker-sys/src/types.rs @@ -21,6 +21,8 @@ mod tls_client_auth; mod version; mod websocket_pair; mod websocket_request_response_pair; +#[cfg(feature = "workflow")] +mod workflow; pub use ai::*; pub use analytics_engine::*; @@ -45,3 +47,5 @@ pub use tls_client_auth::*; pub use version::*; pub use websocket_pair::*; pub use websocket_request_response_pair::*; +#[cfg(feature = "workflow")] +pub use workflow::*; diff --git a/worker-sys/src/types/workflow.rs b/worker-sys/src/types/workflow.rs new file mode 100644 index 000000000..45454edb0 --- /dev/null +++ b/worker-sys/src/types/workflow.rs @@ -0,0 +1,87 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends=js_sys::Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type WorkflowStep; + + #[wasm_bindgen(method, catch, js_name = "do")] + pub fn do_( + this: &WorkflowStep, + name: &str, + callback: &js_sys::Function, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = "do")] + pub fn do_with_config( + this: &WorkflowStep, + name: &str, + config: JsValue, + callback: &js_sys::Function, + ) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn sleep( + this: &WorkflowStep, + name: &str, + duration: JsValue, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = sleepUntil)] + pub fn sleep_until( + this: &WorkflowStep, + name: &str, + timestamp: JsValue, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = waitForEvent)] + pub fn wait_for_event( + this: &WorkflowStep, + name: &str, + options: JsValue, + ) -> Result; + + /// Workflow binding type - may be a Workflow object, WorkflowImpl, or Fetcher (RPC stub). + #[wasm_bindgen(extends=js_sys::Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type WorkflowBinding; + + #[wasm_bindgen(method, catch)] + pub fn get(this: &WorkflowBinding, id: &str) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn create(this: &WorkflowBinding, options: JsValue) -> Result; + + #[wasm_bindgen(method, catch, js_name = createBatch)] + pub fn create_batch( + this: &WorkflowBinding, + batch: &js_sys::Array, + ) -> Result; + + /// Workflow instance handle - may be an RPC stub in Miniflare. + #[wasm_bindgen(extends=js_sys::Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type WorkflowInstanceSys; + + #[wasm_bindgen(method, catch)] + pub fn pause(this: &WorkflowInstanceSys) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn resume(this: &WorkflowInstanceSys) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn terminate(this: &WorkflowInstanceSys) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn restart(this: &WorkflowInstanceSys) -> Result; + + #[wasm_bindgen(method, catch)] + pub fn status(this: &WorkflowInstanceSys) -> Result; + + #[wasm_bindgen(method, catch, js_name = sendEvent)] + pub fn send_event( + this: &WorkflowInstanceSys, + event: JsValue, + ) -> Result; +} diff --git a/worker/Cargo.toml b/worker/Cargo.toml index 2de23f3b2..3aa81c87b 100644 --- a/worker/Cargo.toml +++ b/worker/Cargo.toml @@ -47,6 +47,7 @@ axum = { version = "0.8", optional = true, default-features = false } [features] queue = ["worker-macros/queue", "worker-sys/queue"] d1 = ["worker-sys/d1"] +workflow = ["worker-macros/workflow", "worker-sys/workflow"] http = ["worker-macros/http"] axum = ["dep:axum"] timezone = ["dep:chrono-tz"] diff --git a/worker/src/env.rs b/worker/src/env.rs index 780c2585b..396fbb9b7 100644 --- a/worker/src/env.rs +++ b/worker/src/env.rs @@ -8,6 +8,8 @@ use crate::rate_limit::RateLimiter; use crate::Ai; #[cfg(feature = "queue")] use crate::Queue; +#[cfg(feature = "workflow")] +use crate::Workflow; use crate::{durable::ObjectNamespace, Bucket, DynamicDispatcher, Fetcher, Result, SecretStore}; use crate::{error::Error, hyperdrive::Hyperdrive}; @@ -99,6 +101,12 @@ impl Env { self.get_binding(binding) } + #[cfg(feature = "workflow")] + /// Access a Workflow by the binding name configured in your wrangler.toml file. + pub fn workflow(&self, binding: &str) -> Result { + self.get_binding(binding) + } + /// Access an R2 Bucket by the binding name configured in your wrangler.toml file. pub fn bucket(&self, binding: &str) -> Result { self.get_binding(binding) diff --git a/worker/src/lib.rs b/worker/src/lib.rs index d4f902bdf..0a5ee5a5b 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -149,12 +149,16 @@ use std::result::Result as StdResult; #[doc(hidden)] pub use async_trait; pub use js_sys; +pub use serde_json; +pub use serde_wasm_bindgen; pub use url::Url; pub use wasm_bindgen; pub use wasm_bindgen_futures; pub use web_sys; pub use cf::{Cf, CfResponseProperties, TlsClientAuth}; +#[cfg(feature = "workflow")] +pub use worker_macros::workflow; pub use worker_macros::{consume, durable_object, event, send}; #[doc(hidden)] pub use worker_sys; @@ -196,6 +200,8 @@ pub use crate::socket::*; pub use crate::streams::*; pub use crate::version::*; pub use crate::websocket::*; +#[cfg(feature = "workflow")] +pub use crate::workflow::*; mod abort; mod ai; @@ -240,6 +246,8 @@ mod sql; mod streams; mod version; mod websocket; +#[cfg(feature = "workflow")] +mod workflow; /// A `Result` alias defaulting to [`Error`]. pub type Result = StdResult; diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs new file mode 100644 index 000000000..a3caf619b --- /dev/null +++ b/worker/src/workflow.rs @@ -0,0 +1,526 @@ +//! Cloudflare Workflows support for Rust Workers. + +use std::future::Future; +use std::panic::AssertUnwindSafe; + +use js_sys::{Object, Reflect}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{future_to_promise, JsFuture}; +use worker_sys::types::WorkflowBinding as WorkflowBindingSys; +use worker_sys::types::WorkflowInstanceSys; +use worker_sys::types::WorkflowStep as WorkflowStepSys; + +use crate::env::EnvBinding; +use crate::Result; + +#[doc(hidden)] +pub fn serialize_as_object( + value: &T, +) -> std::result::Result { + value.serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)) +} + +fn get_property(target: &JsValue, name: &str) -> Result { + Reflect::get(target, &JsValue::from_str(name)) + .map_err(|e| crate::Error::JsError(format!("failed to get property '{name}': {e:?}"))) +} + +fn get_string_property(target: &JsValue, name: &str) -> Result { + get_property(target, name)? + .as_string() + .ok_or_else(|| crate::Error::JsError(format!("{name} is not a string"))) +} + +fn get_timestamp_property(target: &JsValue, name: &str) -> Result { + let val = get_property(target, name)?; + Ok(crate::Date::from(js_sys::Date::from(val))) +} + +/// A Workflow binding for creating and managing workflow instances. +#[derive(Debug, Clone)] +pub struct Workflow { + inner: WorkflowBindingSys, +} + +// SAFETY: WASM is single-threaded. These types wrap JS objects that are only +// accessed from the main thread. Send/Sync are implemented to satisfy Rust's +// async machinery (e.g., holding references across await points), but actual +// cross-thread access is impossible in the Workers runtime. +unsafe impl Send for Workflow {} +unsafe impl Sync for Workflow {} + +impl Workflow { + /// Get a handle to an existing workflow instance by ID. + pub async fn get(&self, id: &str) -> Result { + let promise = self.inner.get(id)?; + let result = JsFuture::from(promise).await?; + Ok(WorkflowInstance::from_js(result)) + } + + /// Create a new workflow instance. + pub async fn create( + &self, + options: Option>, + ) -> Result { + let js_options = match options { + Some(opts) => serde_wasm_bindgen::to_value(&opts)?, + None => JsValue::UNDEFINED, + }; + let promise = self.inner.create(js_options)?; + let result = JsFuture::from(promise).await?; + Ok(WorkflowInstance::from_js(result)) + } + + /// Create a batch of workflow instances (limited to 100 at a time). + pub async fn create_batch( + &self, + batch: Vec>, + ) -> Result> { + let js_array = js_sys::Array::new(); + for opts in batch { + js_array.push(&serde_wasm_bindgen::to_value(&opts)?); + } + let promise = self.inner.create_batch(&js_array)?; + let result = JsFuture::from(promise).await?; + let result_array: js_sys::Array = result.unchecked_into(); + + let mut instances = Vec::with_capacity(result_array.length() as usize); + for i in 0..result_array.length() { + instances.push(WorkflowInstance::from_js(result_array.get(i))); + } + Ok(instances) + } +} + +impl EnvBinding for Workflow { + const TYPE_NAME: &'static str = "Workflow"; + + fn get(val: JsValue) -> Result { + let obj = Object::from(val); + let constructor_name = obj.constructor().name(); + if constructor_name == Self::TYPE_NAME + || constructor_name == "WorkflowImpl" + || constructor_name == "Fetcher" + { + Ok(Self { + inner: obj.unchecked_into(), + }) + } else { + Err(format!( + "Binding cannot be cast to the type {} from {}", + Self::TYPE_NAME, + constructor_name + ) + .into()) + } + } +} + +impl JsCast for Workflow { + fn instanceof(_val: &JsValue) -> bool { + true + } + + fn unchecked_from_js(val: JsValue) -> Self { + Self { + inner: val.unchecked_into(), + } + } + + fn unchecked_from_js_ref(val: &JsValue) -> &Self { + unsafe { &*(val as *const JsValue as *const Self) } + } +} + +impl From for JsValue { + fn from(workflow: Workflow) -> Self { + workflow.inner.into() + } +} + +impl AsRef for Workflow { + fn as_ref(&self) -> &JsValue { + self.inner.as_ref() + } +} + +/// Options for creating a new workflow instance. +#[derive(Debug, Clone, Serialize)] +pub struct CreateOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retention: Option, +} + +impl Default for CreateOptions { + fn default() -> Self { + Self { + id: None, + params: None, + retention: None, + } + } +} + +/// Retention policy for workflow instances. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetentionOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub success_retention: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_retention: Option, +} + +/// A handle to a workflow instance. +#[derive(Debug, Clone)] +pub struct WorkflowInstance { + inner: WorkflowInstanceSys, +} + +// SAFETY: See Workflow for rationale - WASM is single-threaded. +unsafe impl Send for WorkflowInstance {} +unsafe impl Sync for WorkflowInstance {} + +impl WorkflowInstance { + fn from_js(val: JsValue) -> Self { + Self { + inner: val.unchecked_into(), + } + } + + /// The unique ID of this workflow instance. + pub fn id(&self) -> Result { + get_string_property(self.inner.as_ref(), "id") + } + + /// Pause the workflow instance. + pub async fn pause(&self) -> Result<()> { + JsFuture::from(self.inner.pause()?).await?; + Ok(()) + } + + /// Resume a paused workflow instance. + pub async fn resume(&self) -> Result<()> { + JsFuture::from(self.inner.resume()?).await?; + Ok(()) + } + + /// Terminate the workflow instance. + pub async fn terminate(&self) -> Result<()> { + JsFuture::from(self.inner.terminate()?).await?; + Ok(()) + } + + /// Restart the workflow instance. + pub async fn restart(&self) -> Result<()> { + JsFuture::from(self.inner.restart()?).await?; + Ok(()) + } + + /// Get the current status of the workflow instance. + pub async fn status(&self) -> Result { + let result = JsFuture::from(self.inner.status()?).await?; + Ok(serde_wasm_bindgen::from_value(result)?) + } + + /// Send an event to the workflow instance to trigger `step.wait_for_event()` calls. + pub async fn send_event(&self, event_type: &str, payload: T) -> Result<()> { + let event = Object::new(); + Reflect::set(&event, &"type".into(), &event_type.into())?; + Reflect::set( + &event, + &"payload".into(), + &serde_wasm_bindgen::to_value(&payload)?, + )?; + JsFuture::from(self.inner.send_event(event.into())?).await?; + Ok(()) + } +} + +/// The status of a workflow instance. +#[derive(Debug, Clone, Deserialize)] +pub struct InstanceStatus { + pub status: InstanceStatusKind, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub output: Option, +} + +/// The possible status values for a workflow instance. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InstanceStatusKind { + Queued, + Running, + Paused, + Errored, + Terminated, + Complete, + Waiting, + WaitingForPause, + Unknown, +} + +/// Error information for a failed workflow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceError { + pub name: String, + pub message: String, +} + +/// Provides methods for executing durable workflow steps. +#[derive(Debug)] +pub struct WorkflowStep(WorkflowStepSys); + +// SAFETY: See Workflow for rationale - WASM is single-threaded. +unsafe impl Send for WorkflowStep {} +unsafe impl Sync for WorkflowStep {} + +impl WorkflowStep { + fn wrap_callback( + callback: F, + ) -> wasm_bindgen::closure::Closure js_sys::Promise> + where + T: Serialize + 'static, + F: FnOnce() -> Fut + 'static, + Fut: Future> + 'static, + { + wasm_bindgen::closure::Closure::once(move || -> js_sys::Promise { + future_to_promise(AssertUnwindSafe(async move { + let result = callback().await.map_err(JsValue::from)?; + serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) + })) + }) + } + + /// Execute a named step. The callback's return value is persisted and + /// returned without re-executing on replay. + pub async fn do_(&self, name: &str, callback: F) -> Result + where + T: Serialize + DeserializeOwned + 'static, + F: FnOnce() -> Fut + 'static, + Fut: Future> + 'static, + { + let closure = Self::wrap_callback(callback); + let js_fn = closure.as_ref().unchecked_ref::(); + let promise = self.0.do_(name, js_fn)?; + let result = JsFuture::from(promise).await?; + Ok(serde_wasm_bindgen::from_value(result)?) + } + + /// Execute a named step with retry and timeout configuration. + pub async fn do_with_config( + &self, + name: &str, + config: StepConfig, + callback: F, + ) -> Result + where + T: Serialize + DeserializeOwned + 'static, + F: FnOnce() -> Fut + 'static, + Fut: Future> + 'static, + { + let config_js = serde_wasm_bindgen::to_value(&config)?; + let closure = Self::wrap_callback(callback); + let js_fn = closure.as_ref().unchecked_ref::(); + let promise = self.0.do_with_config(name, config_js, js_fn)?; + let result = JsFuture::from(promise).await?; + Ok(serde_wasm_bindgen::from_value(result)?) + } + + /// Sleep for a specified duration (e.g., "1 minute", "5 seconds"). + pub async fn sleep(&self, name: &str, duration: impl Into) -> Result<()> { + let duration_js = duration.into().to_js_value(); + JsFuture::from(self.0.sleep(name, duration_js)?).await?; + Ok(()) + } + + /// Sleep until a specific timestamp. + pub async fn sleep_until(&self, name: &str, timestamp: impl Into) -> Result<()> { + let date: crate::Date = timestamp.into(); + let ts_ms = date.as_millis() as f64; + JsFuture::from(self.0.sleep_until(name, ts_ms.into())?).await?; + Ok(()) + } + + /// Wait for an external event sent via `WorkflowInstance::send_event()`. + pub async fn wait_for_event( + &self, + name: &str, + options: WaitForEventOptions, + ) -> Result> { + let options_js = serde_wasm_bindgen::to_value(&options)?; + let result = JsFuture::from(self.0.wait_for_event(name, options_js)?).await?; + WorkflowStepEvent::from_js(result) + } +} + +impl From for WorkflowStep { + fn from(inner: WorkflowStepSys) -> Self { + Self(inner) + } +} + +/// Configuration for a workflow step. +#[derive(Debug, Clone, Default, Serialize)] +pub struct StepConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub retries: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// Retry configuration for a workflow step. +#[derive(Debug, Clone, Serialize)] +pub struct RetryConfig { + pub limit: u32, + pub delay: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub backoff: Option, +} + +/// Backoff strategy for retries. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Backoff { + Constant, + Linear, + Exponential, +} + +/// Options for waiting for an external event. +#[derive(Debug, Clone, Serialize)] +pub struct WaitForEventOptions { + #[serde(rename = "type")] + pub event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// An event received from `wait_for_event`. +#[derive(Debug, Clone)] +pub struct WorkflowStepEvent { + pub payload: T, + pub timestamp: crate::Date, + pub event_type: String, +} + +impl WorkflowStepEvent { + fn from_js(value: JsValue) -> Result { + Ok(Self { + payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, + timestamp: get_timestamp_property(&value, "timestamp")?, + event_type: get_string_property(&value, "type")?, + }) + } +} + +/// The event passed to a workflow's run method. +#[derive(Debug, Clone)] +pub struct WorkflowEvent { + pub payload: T, + pub timestamp: crate::Date, + pub instance_id: String, +} + +impl WorkflowEvent { + pub fn from_js(value: JsValue) -> Result { + Ok(Self { + payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, + timestamp: get_timestamp_property(&value, "timestamp")?, + instance_id: get_string_property(&value, "instanceId")?, + }) + } +} + +/// Duration type for workflow sleep operations. +#[derive(Debug, Clone)] +pub enum WorkflowDuration { + Milliseconds(u64), + String(String), +} + +impl WorkflowDuration { + fn to_js_value(&self) -> JsValue { + match self { + Self::Milliseconds(ms) => JsValue::from_f64(*ms as f64), + Self::String(s) => JsValue::from_str(s), + } + } +} + +impl From<&str> for WorkflowDuration { + fn from(s: &str) -> Self { + Self::String(s.to_string()) + } +} + +impl From for WorkflowDuration { + fn from(s: String) -> Self { + Self::String(s) + } +} + +impl From for WorkflowDuration { + fn from(d: std::time::Duration) -> Self { + Self::Milliseconds(d.as_millis() as u64) + } +} + +/// Error type for non-retryable workflow errors. +#[derive(Debug)] +pub struct NonRetryableError { + message: String, + name: Option, +} + +impl NonRetryableError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + name: None, + } + } + + pub fn with_name(message: impl Into, name: impl Into) -> Self { + Self { + message: message.into(), + name: Some(name.into()), + } + } +} + +impl std::fmt::Display for NonRetryableError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(name) = &self.name { + write!(f, "{}: {}", name, self.message) + } else { + write!(f, "{}", self.message) + } + } +} + +impl std::error::Error for NonRetryableError {} + +/// Marker trait implemented by the `#[workflow]` macro. +#[doc(hidden)] +pub trait HasWorkflowAttribute {} + +/// Trait for implementing a Workflow entrypoint. +#[allow(async_fn_in_trait)] +pub trait WorkflowEntrypoint: HasWorkflowAttribute { + fn new(ctx: crate::Context, env: crate::Env) -> Self; + + async fn run( + &self, + event: WorkflowEvent, + step: WorkflowStep, + ) -> Result; +} From 93f3c5ad8054d20ee81d66fea68765597228054d Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 28 Jan 2026 08:18:09 -0600 Subject: [PATCH 02/21] clippy? clippy fix picked up more things. unclear why the build just flagged these --- worker-macros/src/event.rs | 2 +- worker-macros/src/workflow.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worker-macros/src/event.rs b/worker-macros/src/event.rs index f7c9374d8..104a4e7e2 100644 --- a/worker-macros/src/event.rs +++ b/worker-macros/src/event.rs @@ -28,7 +28,7 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { "respond_with_errors" => { respond_with_errors = true; } - _ => panic!("Invalid attribute: {}", attr), + _ => panic!("Invalid attribute: {attr}"), } } let handler_type = handler_type.expect( diff --git a/worker-macros/src/workflow.rs b/worker-macros/src/workflow.rs index 45fbea7ef..2b3807e72 100644 --- a/worker-macros/src/workflow.rs +++ b/worker-macros/src/workflow.rs @@ -13,7 +13,7 @@ pub fn expand_macro(tokens: TokenStream) -> syn::Result { let target = syn::parse2::(tokens)?; let target_name = &target.ident; let marker_fn_name = format_ident!("__wf_{}", target_name); - let marker_js_name = format!("__wf_{}", target_name); + let marker_js_name = format!("__wf_{target_name}"); let target_name_str = target_name.to_string(); Ok(quote! { From d19624f3fced5c7d7f1df089b13acc8e76d8f620 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 28 Jan 2026 18:04:31 -0600 Subject: [PATCH 03/21] backout toolchain bump --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index ab40f4f44..291696d0e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.87.0" profile = "default" From 4536c0cb902de193364b62c948d8efbaf19ac4f5 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 28 Jan 2026 18:16:25 -0600 Subject: [PATCH 04/21] fix: wrap workflow JsFuture calls with SendFuture for axum compatibility Workflow async methods were failing to compile with the `http` feature because JsFuture is not Send. Wrap all JsFuture calls with SendFuture and extract promises to separate variables to avoid holding non-Send types across await points. --- worker/src/workflow.rs | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index a3caf619b..03010704f 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -13,6 +13,7 @@ use worker_sys::types::WorkflowInstanceSys; use worker_sys::types::WorkflowStep as WorkflowStepSys; use crate::env::EnvBinding; +use crate::send::SendFuture; use crate::Result; #[doc(hidden)] @@ -55,7 +56,7 @@ impl Workflow { /// Get a handle to an existing workflow instance by ID. pub async fn get(&self, id: &str) -> Result { let promise = self.inner.get(id)?; - let result = JsFuture::from(promise).await?; + let result = SendFuture::new(JsFuture::from(promise)).await?; Ok(WorkflowInstance::from_js(result)) } @@ -69,7 +70,7 @@ impl Workflow { None => JsValue::UNDEFINED, }; let promise = self.inner.create(js_options)?; - let result = JsFuture::from(promise).await?; + let result = SendFuture::new(JsFuture::from(promise)).await?; Ok(WorkflowInstance::from_js(result)) } @@ -83,7 +84,7 @@ impl Workflow { js_array.push(&serde_wasm_bindgen::to_value(&opts)?); } let promise = self.inner.create_batch(&js_array)?; - let result = JsFuture::from(promise).await?; + let result = SendFuture::new(JsFuture::from(promise)).await?; let result_array: js_sys::Array = result.unchecked_into(); let mut instances = Vec::with_capacity(result_array.length() as usize); @@ -201,31 +202,36 @@ impl WorkflowInstance { /// Pause the workflow instance. pub async fn pause(&self) -> Result<()> { - JsFuture::from(self.inner.pause()?).await?; + let promise = self.inner.pause()?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } /// Resume a paused workflow instance. pub async fn resume(&self) -> Result<()> { - JsFuture::from(self.inner.resume()?).await?; + let promise = self.inner.resume()?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } /// Terminate the workflow instance. pub async fn terminate(&self) -> Result<()> { - JsFuture::from(self.inner.terminate()?).await?; + let promise = self.inner.terminate()?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } /// Restart the workflow instance. pub async fn restart(&self) -> Result<()> { - JsFuture::from(self.inner.restart()?).await?; + let promise = self.inner.restart()?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } /// Get the current status of the workflow instance. pub async fn status(&self) -> Result { - let result = JsFuture::from(self.inner.status()?).await?; + let promise = self.inner.status()?; + let result = SendFuture::new(JsFuture::from(promise)).await?; Ok(serde_wasm_bindgen::from_value(result)?) } @@ -238,7 +244,8 @@ impl WorkflowInstance { &"payload".into(), &serde_wasm_bindgen::to_value(&payload)?, )?; - JsFuture::from(self.inner.send_event(event.into())?).await?; + let promise = self.inner.send_event(event.into())?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } } @@ -311,7 +318,7 @@ impl WorkflowStep { let closure = Self::wrap_callback(callback); let js_fn = closure.as_ref().unchecked_ref::(); let promise = self.0.do_(name, js_fn)?; - let result = JsFuture::from(promise).await?; + let result = SendFuture::new(JsFuture::from(promise)).await?; Ok(serde_wasm_bindgen::from_value(result)?) } @@ -331,14 +338,15 @@ impl WorkflowStep { let closure = Self::wrap_callback(callback); let js_fn = closure.as_ref().unchecked_ref::(); let promise = self.0.do_with_config(name, config_js, js_fn)?; - let result = JsFuture::from(promise).await?; + let result = SendFuture::new(JsFuture::from(promise)).await?; Ok(serde_wasm_bindgen::from_value(result)?) } /// Sleep for a specified duration (e.g., "1 minute", "5 seconds"). pub async fn sleep(&self, name: &str, duration: impl Into) -> Result<()> { let duration_js = duration.into().to_js_value(); - JsFuture::from(self.0.sleep(name, duration_js)?).await?; + let promise = self.0.sleep(name, duration_js)?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } @@ -346,7 +354,8 @@ impl WorkflowStep { pub async fn sleep_until(&self, name: &str, timestamp: impl Into) -> Result<()> { let date: crate::Date = timestamp.into(); let ts_ms = date.as_millis() as f64; - JsFuture::from(self.0.sleep_until(name, ts_ms.into())?).await?; + let promise = self.0.sleep_until(name, ts_ms.into())?; + SendFuture::new(JsFuture::from(promise)).await?; Ok(()) } @@ -357,7 +366,8 @@ impl WorkflowStep { options: WaitForEventOptions, ) -> Result> { let options_js = serde_wasm_bindgen::to_value(&options)?; - let result = JsFuture::from(self.0.wait_for_event(name, options_js)?).await?; + let promise = self.0.wait_for_event(name, options_js)?; + let result = SendFuture::new(JsFuture::from(promise)).await?; WorkflowStepEvent::from_js(result) } } From 95c8f7e44ef76c41a070bfb26a656fc1d2366ed0 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 20 Feb 2026 17:38:16 -0800 Subject: [PATCH 05/21] fix: wrap workflow callback with AssertUnwindSafe for panic-unwind compatibility --- worker/src/workflow.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 03010704f..67898405f 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -299,7 +299,9 @@ impl WorkflowStep { F: FnOnce() -> Fut + 'static, Fut: Future> + 'static, { + let callback = AssertUnwindSafe(callback); wasm_bindgen::closure::Closure::once(move || -> js_sys::Promise { + let callback = callback.0; future_to_promise(AssertUnwindSafe(async move { let result = callback().await.map_err(JsValue::from)?; serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) From 23c317e28b4d07a3a562b337259c59c10d00d63b Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 25 Feb 2026 19:04:45 -0600 Subject: [PATCH 06/21] fix: use JS NonRetryableError, async bindings, and retryable callbacks - Bind NonRetryableError to the actual JS class from cloudflare:workflows so the runtime correctly identifies non-retryable errors - Add --external cloudflare:workflows to esbuild in worker-build - Change wasm_bindgen extern bindings to async with return typing - Change step callbacks from FnOnce to Fn so retries can re-invoke them - Preserve Error::Internal JsValues through error conversion chain - Update workflow example to demonstrate both retryable and non-retryable error patterns with request body parsing --- Cargo.lock | 1 + examples/workflow/src/lib.rs | 66 ++++++++++++++------ test/src/workflow.rs | 5 +- worker-build/src/main.rs | 1 + worker-build/src/main_legacy.rs | 1 + worker-sys/Cargo.toml | 1 + worker-sys/src/types/workflow.rs | 54 +++++++++------- worker/src/error.rs | 5 +- worker/src/workflow.rs | 102 ++++++++++++++----------------- 9 files changed, 135 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00026a6dd..cbde22fa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3722,6 +3722,7 @@ dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index 82a9e22c6..5e745a200 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -34,14 +34,40 @@ impl WorkflowEntrypoint for MyWorkflow { let params: MyParams = serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + let email_for_validation = params.email.clone(); + step.do_with_config( + "validate-params", + StepConfig { + retries: Some(RetryConfig { + limit: 3, + delay: "1 second".to_string(), + backoff: None, + }), + timeout: None, + }, + move || { + let email = email_for_validation.clone(); + async move { + if !email.contains('@') { + return Err(NonRetryableError::new("invalid email address").into()); + } + Ok(serde_json::json!({ "valid": true })) + } + }, + ) + .await?; + let name_for_step1 = params.name.clone(); let step1_result = step - .do_("initial-processing", move || async move { - console_log!("Processing for user: {}", name_for_step1); - Ok(serde_json::json!({ - "processed": true, - "user": name_for_step1 - })) + .do_("initial-processing", move || { + let name = name_for_step1.clone(); + async move { + console_log!("Processing for user: {}", name); + Ok(serde_json::json!({ + "processed": true, + "user": name + })) + } }) .await?; @@ -62,12 +88,18 @@ impl WorkflowEntrypoint for MyWorkflow { }), timeout: Some("1 minute".to_string()), }, - move || async move { - console_log!("Sending notification to: {}", email_for_step3); - Ok(serde_json::json!({ - "notification_sent": true, - "email": email_for_step3 - })) + move || { + let email = email_for_step3.clone(); + async move { + console_log!("Sending notification to: {}", email); + if js_sys::Math::random() < 0.5 { + return Err("notification service temporarily unavailable".into()); + } + Ok(serde_json::json!({ + "notification_sent": true, + "email": email + })) + } }, ) .await?; @@ -84,23 +116,19 @@ impl WorkflowEntrypoint for MyWorkflow { } #[event(fetch)] -async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { +async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { let url = req.url()?; let path = url.path(); let workflow = env.workflow("MY_WORKFLOW")?; match (req.method(), path) { (Method::Post, "/workflow") => { - let params = MyParams { - email: "user@example.com".to_string(), - name: "Test User".to_string(), - }; + let params: MyParams = req.json().await?; let instance = workflow .create(Some(CreateOptions { - id: None, params: Some(params), - retention: None, + ..Default::default() })) .await?; diff --git a/test/src/workflow.rs b/test/src/workflow.rs index b1cc2b32c..9c6433b3d 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -26,8 +26,9 @@ impl WorkflowEntrypoint for TestWorkflow { serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; let result = step - .do_("process", move || async move { - Ok(serde_json::json!({ "processed": params.value })) + .do_("process", move || { + let params = params.clone(); + async move { Ok(serde_json::json!({ "processed": params.value })) } }) .await?; diff --git a/worker-build/src/main.rs b/worker-build/src/main.rs index 6a7ac5ad6..417502c2a 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -397,6 +397,7 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { "--external:./index_bg.wasm", "--external:cloudflare:sockets", "--external:cloudflare:workers", + "--external:cloudflare:workflows", "--format=esm", "--bundle", "./shim.js", diff --git a/worker-build/src/main_legacy.rs b/worker-build/src/main_legacy.rs index ea027cfa2..8fe9219f9 100644 --- a/worker-build/src/main_legacy.rs +++ b/worker-build/src/main_legacy.rs @@ -157,6 +157,7 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { "--external:./index.wasm", "--external:cloudflare:sockets", "--external:cloudflare:workers", + "--external:cloudflare:workflows", "--format=esm", "--bundle", "./shim.js", diff --git a/worker-sys/Cargo.toml b/worker-sys/Cargo.toml index 25763204c..27795aad1 100644 --- a/worker-sys/Cargo.toml +++ b/worker-sys/Cargo.toml @@ -11,6 +11,7 @@ description = "Low-level extern definitions / FFI bindings to the Cloudflare Wor cfg-if.workspace = true js-sys.workspace = true wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true web-sys.workspace = true [features] diff --git a/worker-sys/src/types/workflow.rs b/worker-sys/src/types/workflow.rs index 45454edb0..ee3ce2d3e 100644 --- a/worker-sys/src/types/workflow.rs +++ b/worker-sys/src/types/workflow.rs @@ -7,40 +7,40 @@ extern "C" { pub type WorkflowStep; #[wasm_bindgen(method, catch, js_name = "do")] - pub fn do_( + pub async fn do_( this: &WorkflowStep, name: &str, callback: &js_sys::Function, - ) -> Result; + ) -> Result; #[wasm_bindgen(method, catch, js_name = "do")] - pub fn do_with_config( + pub async fn do_with_config( this: &WorkflowStep, name: &str, config: JsValue, callback: &js_sys::Function, - ) -> Result; + ) -> Result; #[wasm_bindgen(method, catch)] - pub fn sleep( + pub async fn sleep( this: &WorkflowStep, name: &str, duration: JsValue, - ) -> Result; + ) -> Result; #[wasm_bindgen(method, catch, js_name = sleepUntil)] - pub fn sleep_until( + pub async fn sleep_until( this: &WorkflowStep, name: &str, timestamp: JsValue, - ) -> Result; + ) -> Result; #[wasm_bindgen(method, catch, js_name = waitForEvent)] - pub fn wait_for_event( + pub async fn wait_for_event( this: &WorkflowStep, name: &str, options: JsValue, - ) -> Result; + ) -> Result; /// Workflow binding type - may be a Workflow object, WorkflowImpl, or Fetcher (RPC stub). #[wasm_bindgen(extends=js_sys::Object)] @@ -48,16 +48,16 @@ extern "C" { pub type WorkflowBinding; #[wasm_bindgen(method, catch)] - pub fn get(this: &WorkflowBinding, id: &str) -> Result; + pub async fn get(this: &WorkflowBinding, id: &str) -> Result; #[wasm_bindgen(method, catch)] - pub fn create(this: &WorkflowBinding, options: JsValue) -> Result; + pub async fn create(this: &WorkflowBinding, options: JsValue) -> Result; #[wasm_bindgen(method, catch, js_name = createBatch)] - pub fn create_batch( + pub async fn create_batch( this: &WorkflowBinding, batch: &js_sys::Array, - ) -> Result; + ) -> Result; /// Workflow instance handle - may be an RPC stub in Miniflare. #[wasm_bindgen(extends=js_sys::Object)] @@ -65,23 +65,31 @@ extern "C" { pub type WorkflowInstanceSys; #[wasm_bindgen(method, catch)] - pub fn pause(this: &WorkflowInstanceSys) -> Result; + pub async fn pause(this: &WorkflowInstanceSys) -> Result; #[wasm_bindgen(method, catch)] - pub fn resume(this: &WorkflowInstanceSys) -> Result; + pub async fn resume(this: &WorkflowInstanceSys) -> Result; #[wasm_bindgen(method, catch)] - pub fn terminate(this: &WorkflowInstanceSys) -> Result; + pub async fn terminate(this: &WorkflowInstanceSys) -> Result; #[wasm_bindgen(method, catch)] - pub fn restart(this: &WorkflowInstanceSys) -> Result; + pub async fn restart(this: &WorkflowInstanceSys) -> Result; #[wasm_bindgen(method, catch)] - pub fn status(this: &WorkflowInstanceSys) -> Result; + pub async fn status(this: &WorkflowInstanceSys) -> Result; #[wasm_bindgen(method, catch, js_name = sendEvent)] - pub fn send_event( - this: &WorkflowInstanceSys, - event: JsValue, - ) -> Result; + pub async fn send_event(this: &WorkflowInstanceSys, event: JsValue) + -> Result; +} + +#[wasm_bindgen(module = "cloudflare:workflows")] +extern "C" { + #[wasm_bindgen(extends = js_sys::Error)] + #[derive(Debug, Clone)] + pub type NonRetryableErrorSys; + + #[wasm_bindgen(constructor, js_class = "NonRetryableError")] + pub fn new(message: &str) -> NonRetryableErrorSys; } diff --git a/worker/src/error.rs b/worker/src/error.rs index d57cb72ae..0679671ee 100644 --- a/worker/src/error.rs +++ b/worker/src/error.rs @@ -178,7 +178,10 @@ impl From for Error { impl From for JsValue { fn from(e: Error) -> Self { - JsValue::from_str(&e.to_string()) + match e { + Error::Internal(v) => v, + _ => JsValue::from_str(&e.to_string()), + } } } diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 67898405f..68cb78af8 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -2,15 +2,17 @@ use std::future::Future; use std::panic::AssertUnwindSafe; +use std::rc::Rc; use js_sys::{Object, Reflect}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; -use wasm_bindgen_futures::{future_to_promise, JsFuture}; -use worker_sys::types::WorkflowBinding as WorkflowBindingSys; -use worker_sys::types::WorkflowInstanceSys; -use worker_sys::types::WorkflowStep as WorkflowStepSys; +use wasm_bindgen_futures::future_to_promise; +use worker_sys::types::{ + NonRetryableErrorSys, WorkflowBinding as WorkflowBindingSys, WorkflowInstanceSys, + WorkflowStep as WorkflowStepSys, +}; use crate::env::EnvBinding; use crate::send::SendFuture; @@ -55,8 +57,7 @@ unsafe impl Sync for Workflow {} impl Workflow { /// Get a handle to an existing workflow instance by ID. pub async fn get(&self, id: &str) -> Result { - let promise = self.inner.get(id)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.inner.get(id)).await?; Ok(WorkflowInstance::from_js(result)) } @@ -69,8 +70,7 @@ impl Workflow { Some(opts) => serde_wasm_bindgen::to_value(&opts)?, None => JsValue::UNDEFINED, }; - let promise = self.inner.create(js_options)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.inner.create(js_options)).await?; Ok(WorkflowInstance::from_js(result)) } @@ -83,8 +83,7 @@ impl Workflow { for opts in batch { js_array.push(&serde_wasm_bindgen::to_value(&opts)?); } - let promise = self.inner.create_batch(&js_array)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.inner.create_batch(&js_array)).await?; let result_array: js_sys::Array = result.unchecked_into(); let mut instances = Vec::with_capacity(result_array.length() as usize); @@ -202,36 +201,31 @@ impl WorkflowInstance { /// Pause the workflow instance. pub async fn pause(&self) -> Result<()> { - let promise = self.inner.pause()?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.inner.pause()).await?; Ok(()) } /// Resume a paused workflow instance. pub async fn resume(&self) -> Result<()> { - let promise = self.inner.resume()?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.inner.resume()).await?; Ok(()) } /// Terminate the workflow instance. pub async fn terminate(&self) -> Result<()> { - let promise = self.inner.terminate()?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.inner.terminate()).await?; Ok(()) } /// Restart the workflow instance. pub async fn restart(&self) -> Result<()> { - let promise = self.inner.restart()?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.inner.restart()).await?; Ok(()) } /// Get the current status of the workflow instance. pub async fn status(&self) -> Result { - let promise = self.inner.status()?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.inner.status()).await?; Ok(serde_wasm_bindgen::from_value(result)?) } @@ -244,8 +238,7 @@ impl WorkflowInstance { &"payload".into(), &serde_wasm_bindgen::to_value(&payload)?, )?; - let promise = self.inner.send_event(event.into())?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.inner.send_event(event.into())).await?; Ok(()) } } @@ -296,17 +289,17 @@ impl WorkflowStep { ) -> wasm_bindgen::closure::Closure js_sys::Promise> where T: Serialize + 'static, - F: FnOnce() -> Fut + 'static, + F: Fn() -> Fut + 'static, Fut: Future> + 'static, { - let callback = AssertUnwindSafe(callback); - wasm_bindgen::closure::Closure::once(move || -> js_sys::Promise { - let callback = callback.0; + let callback = Rc::new(AssertUnwindSafe(callback)); + wasm_bindgen::closure::Closure::wrap(Box::new(move || -> js_sys::Promise { + let callback = callback.clone(); future_to_promise(AssertUnwindSafe(async move { - let result = callback().await.map_err(JsValue::from)?; + let result = (callback.0)().await.map_err(JsValue::from)?; serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) })) - }) + }) as Box js_sys::Promise>) } /// Execute a named step. The callback's return value is persisted and @@ -314,13 +307,12 @@ impl WorkflowStep { pub async fn do_(&self, name: &str, callback: F) -> Result where T: Serialize + DeserializeOwned + 'static, - F: FnOnce() -> Fut + 'static, + F: Fn() -> Fut + 'static, Fut: Future> + 'static, { let closure = Self::wrap_callback(callback); let js_fn = closure.as_ref().unchecked_ref::(); - let promise = self.0.do_(name, js_fn)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.0.do_(name, js_fn)).await?; Ok(serde_wasm_bindgen::from_value(result)?) } @@ -333,22 +325,20 @@ impl WorkflowStep { ) -> Result where T: Serialize + DeserializeOwned + 'static, - F: FnOnce() -> Fut + 'static, + F: Fn() -> Fut + 'static, Fut: Future> + 'static, { let config_js = serde_wasm_bindgen::to_value(&config)?; let closure = Self::wrap_callback(callback); let js_fn = closure.as_ref().unchecked_ref::(); - let promise = self.0.do_with_config(name, config_js, js_fn)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.0.do_with_config(name, config_js, js_fn)).await?; Ok(serde_wasm_bindgen::from_value(result)?) } /// Sleep for a specified duration (e.g., "1 minute", "5 seconds"). pub async fn sleep(&self, name: &str, duration: impl Into) -> Result<()> { let duration_js = duration.into().to_js_value(); - let promise = self.0.sleep(name, duration_js)?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.0.sleep(name, duration_js)).await?; Ok(()) } @@ -356,8 +346,7 @@ impl WorkflowStep { pub async fn sleep_until(&self, name: &str, timestamp: impl Into) -> Result<()> { let date: crate::Date = timestamp.into(); let ts_ms = date.as_millis() as f64; - let promise = self.0.sleep_until(name, ts_ms.into())?; - SendFuture::new(JsFuture::from(promise)).await?; + SendFuture::new(self.0.sleep_until(name, ts_ms.into())).await?; Ok(()) } @@ -368,8 +357,7 @@ impl WorkflowStep { options: WaitForEventOptions, ) -> Result> { let options_js = serde_wasm_bindgen::to_value(&options)?; - let promise = self.0.wait_for_event(name, options_js)?; - let result = SendFuture::new(JsFuture::from(promise)).await?; + let result = SendFuture::new(self.0.wait_for_event(name, options_js)).await?; WorkflowStepEvent::from_js(result) } } @@ -487,40 +475,42 @@ impl From for WorkflowDuration { } /// Error type for non-retryable workflow errors. +/// +/// This wraps the JavaScript `NonRetryableError` from `cloudflare:workflows`, +/// which the Workflows runtime uses to identify errors that should not be retried. #[derive(Debug)] pub struct NonRetryableError { - message: String, - name: Option, + inner: NonRetryableErrorSys, } impl NonRetryableError { pub fn new(message: impl Into) -> Self { Self { - message: message.into(), - name: None, - } - } - - pub fn with_name(message: impl Into, name: impl Into) -> Self { - Self { - message: message.into(), - name: Some(name.into()), + inner: NonRetryableErrorSys::new(&message.into()), } } } impl std::fmt::Display for NonRetryableError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(name) = &self.name { - write!(f, "{}: {}", name, self.message) - } else { - write!(f, "{}", self.message) - } + write!(f, "{}", self.inner.message()) } } impl std::error::Error for NonRetryableError {} +impl From for JsValue { + fn from(e: NonRetryableError) -> Self { + e.inner.into() + } +} + +impl From for crate::Error { + fn from(e: NonRetryableError) -> Self { + crate::Error::Internal(e.inner.into()) + } +} + /// Marker trait implemented by the `#[workflow]` macro. #[doc(hidden)] pub trait HasWorkflowAttribute {} From 446dc862a3f21ccd5c89d2cc9d5f3ffe7e61ac03 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 25 Feb 2026 19:24:05 -0600 Subject: [PATCH 07/21] test NonRetryableError --- test/src/router.rs | 1 + test/src/workflow.rs | 42 +++++++++++++++++++++++++++++++++++++ test/tests/workflow.spec.ts | 29 +++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/test/src/router.rs b/test/src/router.rs index 8dd936375..d1d4a7b8c 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -235,6 +235,7 @@ macro_rules! add_routes ( 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, post, "/workflow/create", workflow::handle_workflow_create); + add_route!($obj, post, "/workflow/create-invalid", workflow::handle_workflow_create_invalid); add_route!($obj, get, format_route!("/workflow/status/{}", "id"), workflow::handle_workflow_status); }); diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 9c6433b3d..a1fe2f54e 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -25,6 +25,29 @@ impl WorkflowEntrypoint for TestWorkflow { let params: TestParams = serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + let value_for_validation = params.value.clone(); + step.do_with_config( + "validate", + StepConfig { + retries: Some(RetryConfig { + limit: 2, + delay: "1 second".to_string(), + backoff: None, + }), + timeout: None, + }, + move || { + let value = value_for_validation.clone(); + async move { + if value.is_empty() { + return Err(NonRetryableError::new("value must not be empty").into()); + } + Ok(serde_json::json!({ "valid": true })) + } + }, + ) + .await?; + let result = step .do_("process", move || { let params = params.clone(); @@ -55,6 +78,25 @@ pub async fn handle_workflow_create( Response::from_json(&serde_json::json!({ "id": instance.id()? })) } +pub async fn handle_workflow_create_invalid( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let workflow = env.workflow("TEST_WORKFLOW")?; + let params = TestParams { + value: "".to_string(), + }; + let instance = workflow + .create(Some(CreateOptions { + params: Some(params), + ..Default::default() + })) + .await?; + + Response::from_json(&serde_json::json!({ "id": instance.id()? })) +} + pub async fn handle_workflow_status( req: Request, env: Env, diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index 9e7cce89e..001b1fb79 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -34,4 +34,33 @@ describe("workflow", () => { expect(status).toBe("Complete"); expect(output).toEqual({ processed: "hello" }); }); + + test("non-retryable error stops workflow immediately", async () => { + const createResp = await mf.dispatchFetch( + `${mfUrl}workflow/create-invalid`, + { method: "POST" } + ); + expect(createResp.status).toBe(200); + const { id } = (await createResp.json()) as { id: string }; + expect(id).toBeDefined(); + + let status: string | undefined; + for (let i = 0; i < 30; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const statusResp = await mf.dispatchFetch( + `${mfUrl}workflow/status/${id}` + ); + expect(statusResp.status).toBe(200); + const body = (await statusResp.json()) as { + status: string; + output: unknown; + }; + status = body.status; + if (status === "Complete" || status === "Errored") { + break; + } + } + + expect(status).toBe("Errored"); + }); }); From 55ab5636be82b26f48a5a3ed23520b4c383a9cba Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 11 Mar 2026 19:31:41 -0500 Subject: [PATCH 08/21] Simplify workflow code: fix __wf_ handler leak, deduplicate test helpers, improve efficiency - Exclude __wf_* marker functions from generate_handlers to prevent spurious Entrypoint.prototype entries - Replace raw Reflect::set calls in send_event with a single serde serialization - Cache result_array.length() in create_batch to avoid redundant JS boundary crossings - Reorder expand_macro to parse ItemStruct first, avoiding unnecessary clone+parse on success path - Extract shared create_workflow_with_value helper from duplicated test handlers - Poll immediately before sleeping in test loops to reduce CI latency --- test/src/workflow.rs | 29 +++++++++++------------------ test/tests/workflow.spec.ts | 4 ++-- worker-build/src/main.rs | 4 ++-- worker-macros/src/workflow.rs | 20 ++++++++++++-------- worker/src/workflow.rs | 24 ++++++++++++++---------- 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/test/src/workflow.rs b/test/src/workflow.rs index a1fe2f54e..91fb56fd5 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -59,14 +59,10 @@ impl WorkflowEntrypoint for TestWorkflow { } } -pub async fn handle_workflow_create( - _req: Request, - env: Env, - _data: crate::SomeSharedData, -) -> Result { +async fn create_workflow_with_value(env: &Env, value: &str) -> Result { let workflow = env.workflow("TEST_WORKFLOW")?; let params = TestParams { - value: "hello".to_string(), + value: value.to_string(), }; let instance = workflow .create(Some(CreateOptions { @@ -78,23 +74,20 @@ pub async fn handle_workflow_create( Response::from_json(&serde_json::json!({ "id": instance.id()? })) } -pub async fn handle_workflow_create_invalid( +pub async fn handle_workflow_create( _req: Request, env: Env, _data: crate::SomeSharedData, ) -> Result { - let workflow = env.workflow("TEST_WORKFLOW")?; - let params = TestParams { - value: "".to_string(), - }; - let instance = workflow - .create(Some(CreateOptions { - params: Some(params), - ..Default::default() - })) - .await?; + create_workflow_with_value(&env, "hello").await +} - Response::from_json(&serde_json::json!({ "id": instance.id()? })) +pub async fn handle_workflow_create_invalid( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + create_workflow_with_value(&env, "").await } pub async fn handle_workflow_status( diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index 001b1fb79..46798da9e 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -14,7 +14,6 @@ describe("workflow", () => { let status: string | undefined; let output: unknown; for (let i = 0; i < 30; i++) { - await new Promise((resolve) => setTimeout(resolve, 500)); const statusResp = await mf.dispatchFetch( `${mfUrl}workflow/status/${id}` ); @@ -29,6 +28,7 @@ describe("workflow", () => { if (status === "Complete" || status === "Errored") { break; } + await new Promise((resolve) => setTimeout(resolve, 500)); } expect(status).toBe("Complete"); @@ -46,7 +46,6 @@ describe("workflow", () => { let status: string | undefined; for (let i = 0; i < 30; i++) { - await new Promise((resolve) => setTimeout(resolve, 500)); const statusResp = await mf.dispatchFetch( `${mfUrl}workflow/status/${id}` ); @@ -59,6 +58,7 @@ describe("workflow", () => { if (status === "Complete" || status === "Errored") { break; } + await new Promise((resolve) => setTimeout(resolve, 500)); } expect(status).toBe("Errored"); diff --git a/worker-build/src/main.rs b/worker-build/src/main.rs index 2d14fb541..97f9dc157 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -169,7 +169,7 @@ fn generate_handlers(out_dir: &Path) -> Result { if let Some(bracket_pos) = rest.find("(") { let func_name = rest[..bracket_pos].trim(); // strip the exported function (we re-wrap all handlers) - if !SYSTEM_FNS.contains(&func_name) { + if !SYSTEM_FNS.contains(&func_name) && !func_name.starts_with("__wf_") { func_names.push(func_name); } } @@ -178,7 +178,7 @@ fn generate_handlers(out_dir: &Path) -> Result { let rest = &rest[as_pos + 4..]; if let Some(brace_pos) = rest.find("}") { let func_name = rest[..brace_pos].trim(); - if !SYSTEM_FNS.contains(&func_name) { + if !SYSTEM_FNS.contains(&func_name) && !func_name.starts_with("__wf_") { func_names.push(func_name); } } diff --git a/worker-macros/src/workflow.rs b/worker-macros/src/workflow.rs index 2b3807e72..12a06f9dd 100644 --- a/worker-macros/src/workflow.rs +++ b/worker-macros/src/workflow.rs @@ -3,14 +3,18 @@ use quote::{format_ident, quote}; use syn::{Error, ItemImpl, ItemStruct}; pub fn expand_macro(tokens: TokenStream) -> syn::Result { - if syn::parse2::(tokens.clone()).is_ok() { - return Err(Error::new( - proc_macro2::Span::call_site(), - "#[workflow] should only be applied to struct definitions, not impl blocks", - )); - } - - let target = syn::parse2::(tokens)?; + let target = match syn::parse2::(tokens.clone()) { + Ok(s) => s, + Err(e) => { + if syn::parse2::(tokens).is_ok() { + return Err(Error::new( + proc_macro2::Span::call_site(), + "#[workflow] should only be applied to struct definitions, not impl blocks", + )); + } + return Err(e); + } + }; let target_name = &target.ident; let marker_fn_name = format_ident!("__wf_{}", target_name); let marker_js_name = format!("__wf_{target_name}"); diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 68cb78af8..7f5729d5e 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -86,8 +86,9 @@ impl Workflow { let result = SendFuture::new(self.inner.create_batch(&js_array)).await?; let result_array: js_sys::Array = result.unchecked_into(); - let mut instances = Vec::with_capacity(result_array.length() as usize); - for i in 0..result_array.length() { + let len = result_array.length(); + let mut instances = Vec::with_capacity(len as usize); + for i in 0..len { instances.push(WorkflowInstance::from_js(result_array.get(i))); } Ok(instances) @@ -231,14 +232,17 @@ impl WorkflowInstance { /// Send an event to the workflow instance to trigger `step.wait_for_event()` calls. pub async fn send_event(&self, event_type: &str, payload: T) -> Result<()> { - let event = Object::new(); - Reflect::set(&event, &"type".into(), &event_type.into())?; - Reflect::set( - &event, - &"payload".into(), - &serde_wasm_bindgen::to_value(&payload)?, - )?; - SendFuture::new(self.inner.send_event(event.into())).await?; + #[derive(Serialize)] + struct SendEventPayload<'a, P: Serialize> { + #[serde(rename = "type")] + event_type: &'a str, + payload: P, + } + let event = serde_wasm_bindgen::to_value(&SendEventPayload { + event_type, + payload, + })?; + SendFuture::new(self.inner.send_event(event)).await?; Ok(()) } } From 501c3c820c397eac65a70acb0d2796b7a4bd583c Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 11 Mar 2026 20:06:39 -0500 Subject: [PATCH 09/21] test: add wait_for_event/send_event workflow integration test Exercises the WorkflowStepEvent::from_js deserialization and WorkflowInstance::send_event serialization round-trip that wasn't covered by existing tests. --- test/src/router.rs | 3 ++ test/src/workflow.rs | 74 +++++++++++++++++++++++++++++++++++++ test/tests/mf.ts | 5 +++ test/tests/workflow.spec.ts | 48 ++++++++++++++++++++++++ test/wrangler.toml | 5 +++ 5 files changed, 135 insertions(+) diff --git a/test/src/router.rs b/test/src/router.rs index d1d4a7b8c..fd09ddde7 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -237,6 +237,9 @@ macro_rules! add_routes ( add_route!($obj, post, "/workflow/create", workflow::handle_workflow_create); add_route!($obj, post, "/workflow/create-invalid", workflow::handle_workflow_create_invalid); add_route!($obj, get, format_route!("/workflow/status/{}", "id"), workflow::handle_workflow_status); + add_route!($obj, post, "/workflow/event/create", workflow::handle_event_workflow_create); + add_route!($obj, post, format_route!("/workflow/event/send/{}", "id"), workflow::handle_event_workflow_send); + add_route!($obj, get, format_route!("/workflow/event/status/{}", "id"), workflow::handle_event_workflow_status); }); #[cfg(feature = "http")] diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 91fb56fd5..f1c0512dd 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -108,3 +108,77 @@ pub async fn handle_workflow_status( "error": status.error, })) } + +#[workflow] +pub struct EventWorkflow { + #[allow(dead_code)] + env: Env, +} + +impl WorkflowEntrypoint for EventWorkflow { + fn new(_ctx: Context, env: Env) -> Self { + Self { env } + } + + async fn run( + &self, + _event: WorkflowEvent, + step: WorkflowStep, + ) -> Result { + let event = step + .wait_for_event::( + "wait-for-approval", + WaitForEventOptions { + event_type: "approval".to_string(), + timeout: Some("30 seconds".to_string()), + }, + ) + .await?; + + Ok(serde_json::json!({ + "payload": event.payload, + "event_type": event.event_type, + })) + } +} + +pub async fn handle_event_workflow_create( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let workflow = env.workflow("EVENT_WORKFLOW")?; + let instance = workflow.create(None::>).await?; + Response::from_json(&serde_json::json!({ "id": instance.id()? })) +} + +pub async fn handle_event_workflow_send( + mut req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let url = req.url()?; + let id = url.path().trim_start_matches("/workflow/event/send/"); + let payload: serde_json::Value = req.json().await?; + let workflow = env.workflow("EVENT_WORKFLOW")?; + let instance = workflow.get(id).await?; + instance.send_event("approval", payload).await?; + Response::ok("sent") +} + +pub async fn handle_event_workflow_status( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let url = req.url()?; + let id = url.path().trim_start_matches("/workflow/event/status/"); + let workflow = env.workflow("EVENT_WORKFLOW")?; + let instance = workflow.get(id).await?; + let status = instance.status().await?; + Response::from_json(&serde_json::json!({ + "status": format!("{:?}", status.status), + "output": status.output, + "error": status.error, + })) +} diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 26ba3afe7..195501272 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -122,6 +122,11 @@ const mf_instance = new Miniflare({ className: "TestWorkflow", scriptName: "workflow-worker", }, + EVENT_WORKFLOW: { + name: "event-workflow", + className: "EventWorkflow", + scriptName: "workflow-worker", + }, }, ratelimits: { TEST_RATE_LIMITER: { diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index 46798da9e..c14531f9a 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -63,4 +63,52 @@ describe("workflow", () => { expect(status).toBe("Errored"); }); + + test("wait_for_event receives sent event", async () => { + const createResp = await mf.dispatchFetch( + `${mfUrl}workflow/event/create`, + { method: "POST" } + ); + expect(createResp.status).toBe(200); + const { id } = (await createResp.json()) as { id: string }; + expect(id).toBeDefined(); + + // Give the workflow time to reach wait_for_event + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Send the event + const sendResp = await mf.dispatchFetch( + `${mfUrl}workflow/event/send/${id}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approved: true, reason: "looks good" }), + } + ); + expect(sendResp.status).toBe(200); + + // Poll until complete + let status: string | undefined; + let output: any; + for (let i = 0; i < 30; i++) { + const statusResp = await mf.dispatchFetch( + `${mfUrl}workflow/event/status/${id}` + ); + expect(statusResp.status).toBe(200); + const body = (await statusResp.json()) as { + status: string; + output: any; + }; + status = body.status; + output = body.output; + if (status === "Complete" || status === "Errored") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + expect(status).toBe("Complete"); + expect(output.payload).toEqual({ approved: true, reason: "looks good" }); + expect(output.event_type).toBe("approval"); + }); }); diff --git a/test/wrangler.toml b/test/wrangler.toml index 85f141ab6..a38d78fb6 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -90,6 +90,11 @@ name = "test-workflow" binding = "TEST_WORKFLOW" class_name = "TestWorkflow" +[[workflows]] +name = "event-workflow" +binding = "EVENT_WORKFLOW" +class_name = "EventWorkflow" + [[ratelimits]] name = "TEST_RATE_LIMITER" namespace_id = "1" From 99b52f9148071b9411cf152f21a1f79bb0428bdb Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Tue, 31 Mar 2026 19:08:00 -0500 Subject: [PATCH 10/21] add test coverage for pause(), resume(), restart(), and terminate() https://developers.cloudflare.com/changelog/post/2026-03-23-local-dev-instance-methods/ --- package-lock.json | 519 ++++++++++++++++++------------------ package.json | 2 +- test/src/durable.rs | 2 - test/src/router.rs | 6 + test/src/workflow.rs | 143 ++++++++-- test/tests/mf.ts | 10 +- test/tests/workflow.spec.ts | 78 ++++++ test/wrangler.toml | 5 + worker/src/error.rs | 36 +-- 9 files changed, 489 insertions(+), 312 deletions(-) diff --git a/package-lock.json b/package-lock.json index 290111042..9b5387e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20250923.0", + "miniflare": "^4.20260329.0", "typescript": "^5.8.3", "undici": "7.14.0", "uuid": "^11.1.0", @@ -351,9 +351,9 @@ "license": "MIT" }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250923.0.tgz", - "integrity": "sha512-CUyVkdTaREdT/wynh5/VX3prawWpYeoqGjcEyo920/HqXaRuA/owp9ijg1vh1rmHyxN0XvsjHuRwBfnrptGmrg==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260329.1.tgz", + "integrity": "sha512-oyDXYlPBuGXKkZ85+M3jFz0/qYmvA4AEURN8USIGPDCR5q+HFSRwywSd9neTx3Wi7jhey2wuYaEpD3fEFWyWUA==", "cpu": [ "x64" ], @@ -368,9 +368,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250923.0.tgz", - "integrity": "sha512-wblU5WYlNRnrTMupeWFRoysJH/Y7d6h+Wc1G+GTmaMV6TcxyXj804Hk8Tk3jqvaS0SXmkh5sIQ38MBVrBs7sag==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260329.1.tgz", + "integrity": "sha512-++ZxVa3ovzYeDLEG6zMqql9gzZAG8vak6ZSBQgprGKZp7akr+GKTpw9f3RrMP552NSi3gTisroLobrrkPBtYLQ==", "cpu": [ "arm64" ], @@ -385,9 +385,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250923.0.tgz", - "integrity": "sha512-WFP0KBJWhdDJWChIw3HJmrtYLNQDB8X9R3o548FcE5NiD05J0rI5Pnhno008lanjmXzia1lghWIQErfpjpmQzg==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260329.1.tgz", + "integrity": "sha512-kkeywAgIHwbqHkVILqbj/YkfbrA6ARbmutjiYzZA2MwMSfNXlw6/kedAKOY8YwcymZIgepx3YTIPnBP50pOotw==", "cpu": [ "x64" ], @@ -402,9 +402,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250923.0.tgz", - "integrity": "sha512-h5VegEhn9CPfGnTc4JTFLGzR806naeIcKUHWi8ejc1hO38YthC6mBNsOjbZrSkp4B3H/kstuhuW16x2rGX0oBg==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260329.1.tgz", + "integrity": "sha512-eYBN20+B7XOUSWEe0mlqkMUbfLoIKjKZnpqQiSxnLbL72JKY0D/KlfN/b7RVGLpewB7i8rTrwTNr0szCKnZzSQ==", "cpu": [ "arm64" ], @@ -419,9 +419,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250923.0.tgz", - "integrity": "sha512-S1E2Vm11ClrwHLqbVO59pKpxllHkq3APRdt3wCbVZNG7jGctxEYLH2uE0K1O1JBfbk2fKMaa+sbmtMpXDXi1tw==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260329.1.tgz", + "integrity": "sha512-5R+/oxrDhS9nL3oA3ZWtD6ndMOqm7RfKknDNxLcmYW5DkUu7UH3J/s1t/Dz66iFePzr5BJmE7/8gbmve6TjtZQ==", "cpu": [ "x64" ], @@ -455,9 +455,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -907,10 +907,20 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -927,13 +937,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -950,13 +960,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -971,9 +981,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -988,9 +998,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -1005,9 +1015,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -1021,10 +1031,44 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -1039,9 +1083,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -1056,9 +1100,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -1073,9 +1117,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -1090,9 +1134,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -1109,13 +1153,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -1132,13 +1176,59 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -1155,13 +1245,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1178,13 +1268,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1201,13 +1291,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1224,13 +1314,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], @@ -1238,8 +1328,28 @@ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1248,9 +1358,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1268,9 +1378,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1943,29 +2053,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2094,51 +2181,6 @@ "node": ">=8" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", @@ -2207,9 +2249,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", - "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2332,19 +2374,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -2452,13 +2481,6 @@ "node": ">= 6" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -2519,13 +2541,6 @@ "node": ">= 4" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "dev": true, - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2680,24 +2695,18 @@ } }, "node_modules/miniflare": { - "version": "4.20250923.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250923.0.tgz", - "integrity": "sha512-CtO0w3tKr8rl5nS5TchYNGQaXuYLfl1T+IqKQiEoIRAUpVWdiziK49+mKV+Vz6yRENqHEGMYV8EjhfvmEHrJpA==", + "version": "4.20260329.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260329.0.tgz", + "integrity": "sha512-+G+1YFVeuEpw/gZZmUHQR7IfzJV+DDGvnSl0yXzhgvHh8Nbr8Go5uiWIwl17EyZ1Uors3FKUMDUyU6+ejeKZOw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", - "sharp": "^0.33.5", - "stoppable": "1.1.0", - "undici": "7.14.0", - "workerd": "1.20250923.0", + "sharp": "^0.34.5", + "undici": "7.24.4", + "workerd": "1.20260329.1", "ws": "8.18.0", - "youch": "4.1.0-beta.10", - "zod": "3.22.3" + "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" @@ -2706,6 +2715,16 @@ "node": ">=18.0.0" } }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3090,9 +3109,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3102,16 +3121,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -3120,25 +3139,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -3181,16 +3205,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3240,17 +3254,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4", - "npm": ">=6" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3746,9 +3749,9 @@ } }, "node_modules/workerd": { - "version": "1.20250923.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250923.0.tgz", - "integrity": "sha512-avGZgJe3Vug0ff8oq5Hpa//x0dF9b12jKhDKqaEZaWl7mVGQk+GaA9lrO8TyJxzlfPIr/rXdvcRYJi/hbdgIJw==", + "version": "1.20260329.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260329.1.tgz", + "integrity": "sha512-+ifMv3uBuD33ee7pan5n8+sgVxm2u5HnbgfXzHKwMNTKw86znqBJSnJoBqtP88+2T5U2Lu11xXUt+khPYioXwQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3759,11 +3762,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250923.0", - "@cloudflare/workerd-darwin-arm64": "1.20250923.0", - "@cloudflare/workerd-linux-64": "1.20250923.0", - "@cloudflare/workerd-linux-arm64": "1.20250923.0", - "@cloudflare/workerd-windows-64": "1.20250923.0" + "@cloudflare/workerd-darwin-64": "1.20260329.1", + "@cloudflare/workerd-darwin-arm64": "1.20260329.1", + "@cloudflare/workerd-linux-64": "1.20260329.1", + "@cloudflare/workerd-linux-arm64": "1.20260329.1", + "@cloudflare/workerd-windows-64": "1.20260329.1" } }, "node_modules/ws": { @@ -3812,16 +3815,6 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } - }, - "node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 92ef190dd..576aadc3c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "homepage": "https://github.com/cloudflare/workers-rs#readme", "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20250923.0", + "miniflare": "^4.20260329.0", "typescript": "^5.8.3", "undici": "7.14.0", "uuid": "^11.1.0", diff --git a/test/src/durable.rs b/test/src/durable.rs index c99e646f0..99e0fb345 100644 --- a/test/src/durable.rs +++ b/test/src/durable.rs @@ -23,8 +23,6 @@ pub struct QueryParams { impl DurableObject for MyClass { fn new(state: State, _env: Env) -> Self { - // Unfortunately we can't access the `name` property within the Durable Object (see ). Instead, we can pass it as a request parameter. - assert!(state.id().name().is_none()); Self { state, number: RefCell::new(0), diff --git a/test/src/router.rs b/test/src/router.rs index fd09ddde7..80a90dd2d 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -240,6 +240,12 @@ macro_rules! add_routes ( add_route!($obj, post, "/workflow/event/create", workflow::handle_event_workflow_create); add_route!($obj, post, format_route!("/workflow/event/send/{}", "id"), workflow::handle_event_workflow_send); add_route!($obj, get, format_route!("/workflow/event/status/{}", "id"), workflow::handle_event_workflow_status); + add_route!($obj, post, "/workflow/lifecycle/create", workflow::handle_lifecycle_workflow_create); + add_route!($obj, get, format_route!("/workflow/lifecycle/status/{}", "id"), workflow::handle_lifecycle_workflow_status); + add_route!($obj, post, format_route!("/workflow/lifecycle/pause/{}", "id"), workflow::handle_lifecycle_workflow_pause); + add_route!($obj, post, format_route!("/workflow/lifecycle/resume/{}", "id"), workflow::handle_lifecycle_workflow_resume); + add_route!($obj, post, format_route!("/workflow/lifecycle/terminate/{}", "id"), workflow::handle_lifecycle_workflow_terminate); + add_route!($obj, post, format_route!("/workflow/lifecycle/restart/{}", "id"), workflow::handle_lifecycle_workflow_restart); }); #[cfg(feature = "http")] diff --git a/test/src/workflow.rs b/test/src/workflow.rs index f1c0512dd..9707c2bd9 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -1,6 +1,37 @@ use serde::{Deserialize, Serialize}; use worker::*; +fn last_path_segment(req: &Request) -> Result { + let url = req.url()?; + url.path_segments() + .and_then(|s| s.last().map(String::from)) + .ok_or_else(|| Error::RustError("missing path segment".into())) +} + +async fn get_workflow_instance( + req: &Request, + env: &Env, + binding: &str, +) -> Result { + let id = last_path_segment(req)?; + let workflow = env.workflow(binding)?; + workflow.get(&id).await +} + +async fn create_workflow_no_params(env: &Env, binding: &str) -> Result { + let workflow = env.workflow(binding)?; + let instance = workflow.create(None::>).await?; + Response::from_json(&serde_json::json!({ "id": instance.id()? })) +} + +fn status_response(status: InstanceStatus) -> Result { + Response::from_json(&serde_json::json!({ + "status": format!("{:?}", status.status), + "output": status.output, + "error": status.error, + })) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestParams { pub value: String, @@ -95,18 +126,9 @@ pub async fn handle_workflow_status( env: Env, _data: crate::SomeSharedData, ) -> Result { - let url = req.url()?; - let path = url.path(); - let id = path.trim_start_matches("/workflow/status/"); - let workflow = env.workflow("TEST_WORKFLOW")?; - let instance = workflow.get(id).await?; + let instance = get_workflow_instance(&req, &env, "TEST_WORKFLOW").await?; let status = instance.status().await?; - - Response::from_json(&serde_json::json!({ - "status": format!("{:?}", status.status), - "output": status.output, - "error": status.error, - })) + status_response(status) } #[workflow] @@ -147,9 +169,7 @@ pub async fn handle_event_workflow_create( env: Env, _data: crate::SomeSharedData, ) -> Result { - let workflow = env.workflow("EVENT_WORKFLOW")?; - let instance = workflow.create(None::>).await?; - Response::from_json(&serde_json::json!({ "id": instance.id()? })) + create_workflow_no_params(&env, "EVENT_WORKFLOW").await } pub async fn handle_event_workflow_send( @@ -157,11 +177,8 @@ pub async fn handle_event_workflow_send( env: Env, _data: crate::SomeSharedData, ) -> Result { - let url = req.url()?; - let id = url.path().trim_start_matches("/workflow/event/send/"); + let instance = get_workflow_instance(&req, &env, "EVENT_WORKFLOW").await?; let payload: serde_json::Value = req.json().await?; - let workflow = env.workflow("EVENT_WORKFLOW")?; - let instance = workflow.get(id).await?; instance.send_event("approval", payload).await?; Response::ok("sent") } @@ -171,14 +188,86 @@ pub async fn handle_event_workflow_status( env: Env, _data: crate::SomeSharedData, ) -> Result { - let url = req.url()?; - let id = url.path().trim_start_matches("/workflow/event/status/"); - let workflow = env.workflow("EVENT_WORKFLOW")?; - let instance = workflow.get(id).await?; + let instance = get_workflow_instance(&req, &env, "EVENT_WORKFLOW").await?; let status = instance.status().await?; - Response::from_json(&serde_json::json!({ - "status": format!("{:?}", status.status), - "output": status.output, - "error": status.error, - })) + status_response(status) +} + +#[workflow] +pub struct LifecycleWorkflow { + #[allow(dead_code)] + env: Env, +} + +impl WorkflowEntrypoint for LifecycleWorkflow { + fn new(_ctx: Context, env: Env) -> Self { + Self { env } + } + + async fn run( + &self, + _event: WorkflowEvent, + step: WorkflowStep, + ) -> Result { + step.sleep("long-sleep", "60 seconds").await?; + Ok(serde_json::json!({ "done": true })) + } +} + +pub async fn handle_lifecycle_workflow_create( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + create_workflow_no_params(&env, "LIFECYCLE_WORKFLOW").await +} + +pub async fn handle_lifecycle_workflow_status( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + let status = instance.status().await?; + status_response(status) +} + +pub async fn handle_lifecycle_workflow_pause( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + instance.pause().await?; + Response::ok("paused") +} + +pub async fn handle_lifecycle_workflow_resume( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + instance.resume().await?; + Response::ok("resumed") +} + +pub async fn handle_lifecycle_workflow_terminate( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + instance.terminate().await?; + Response::ok("terminated") +} + +pub async fn handle_lifecycle_workflow_restart( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + instance.restart().await?; + Response::ok("restarted") } diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 195501272..bd65b3418 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -1,7 +1,6 @@ -import { Miniflare, Response } from "miniflare"; -import { MockAgent } from "undici"; +import { Miniflare, Response, createFetchMock } from "miniflare"; -const mockAgent = new MockAgent(); +const mockAgent = createFetchMock(); mockAgent .get("https://cloudflare.com") @@ -127,6 +126,11 @@ const mf_instance = new Miniflare({ className: "EventWorkflow", scriptName: "workflow-worker", }, + LIFECYCLE_WORKFLOW: { + name: "lifecycle-workflow", + className: "LifecycleWorkflow", + scriptName: "workflow-worker", + }, }, ratelimits: { TEST_RATE_LIMITER: { diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index c14531f9a..0aa496f3b 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -64,6 +64,84 @@ describe("workflow", () => { expect(status).toBe("Errored"); }); + async function lifecycleStatus( + id: string + ): Promise<{ status: string; error: unknown }> { + const resp = await mf.dispatchFetch( + `${mfUrl}workflow/lifecycle/status/${id}` + ); + return (await resp.json()) as { status: string; error: unknown }; + } + + async function pollUntil( + id: string, + predicate: (status: string) => boolean + ): Promise { + for (let i = 0; i < 10; i++) { + const { status } = await lifecycleStatus(id); + if (predicate(status)) return status; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const { status } = await lifecycleStatus(id); + return status; + } + + async function createLifecycleWorkflow(): Promise { + const resp = await mf.dispatchFetch( + `${mfUrl}workflow/lifecycle/create`, + { method: "POST" } + ); + expect(resp.status).toBe(200); + const { id } = (await resp.json()) as { id: string }; + await pollUntil(id, (s) => s !== "Queued"); + return id; + } + + async function lifecycleAction( + action: string, + id: string + ): Promise { + return mf.dispatchFetch( + `${mfUrl}workflow/lifecycle/${action}/${id}`, + { method: "POST" } + ); + } + + test("pause and resume a running workflow", async () => { + const id = await createLifecycleWorkflow(); + + expect((await lifecycleAction("pause", id)).status).toBe(200); + const paused = await pollUntil(id, (s) => s === "Paused"); + expect(paused).toBe("Paused"); + + expect((await lifecycleAction("resume", id)).status).toBe(200); + const resumed = await pollUntil(id, (s) => s !== "Paused"); + expect(resumed).not.toBe("Paused"); + + await lifecycleAction("terminate", id); + }); + + test("terminate a running workflow", async () => { + const id = await createLifecycleWorkflow(); + + expect((await lifecycleAction("terminate", id)).status).toBe(200); + const status = await pollUntil(id, (s) => s === "Terminated"); + expect(status).toBe("Terminated"); + }); + + test("restart a running workflow", async () => { + const id = await createLifecycleWorkflow(); + + expect((await lifecycleAction("restart", id)).status).toBe(200); + const status = await pollUntil( + id, + (s) => s !== "Queued" + ); + expect(["Running", "Waiting", "Queued"]).toContain(status); + + await lifecycleAction("terminate", id); + }); + test("wait_for_event receives sent event", async () => { const createResp = await mf.dispatchFetch( `${mfUrl}workflow/event/create`, diff --git a/test/wrangler.toml b/test/wrangler.toml index a38d78fb6..bb9c60c7d 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -95,6 +95,11 @@ name = "event-workflow" binding = "EVENT_WORKFLOW" class_name = "EventWorkflow" +[[workflows]] +name = "lifecycle-workflow" +binding = "LIFECYCLE_WORKFLOW" +class_name = "LifecycleWorkflow" + [[ratelimits]] name = "TEST_RATE_LIMITER" namespace_id = "1" diff --git a/worker/src/error.rs b/worker/src/error.rs index 0679671ee..eec2ae6e6 100644 --- a/worker/src/error.rs +++ b/worker/src/error.rs @@ -124,7 +124,19 @@ impl std::fmt::Display for Error { #[cfg(feature = "http")] Error::Http(e) => write!(f, "http::Error: {e}"), Error::Infallible => write!(f, "infallible"), - Error::Internal(_) => write!(f, "unrecognized JavaScript object"), + Error::Internal(v) => { + if let Some(e) = v.dyn_ref::() { + let name = String::from(e.name()); + let msg = String::from(e.message()); + if name.is_empty() { + write!(f, "{msg}") + } else { + write!(f, "{name}: {msg}") + } + } else { + write!(f, "unrecognized JavaScript object") + } + } Error::Io(e) => write!(f, "IO Error: {e}"), Error::BindingError(name) => write!(f, "no binding found for `{name}`"), Error::RouteInsertError(e) => write!(f, "failed to insert route: {e}"), @@ -149,24 +161,16 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} -// Not sure if the changes I've made here are good or bad... impl From for Error { fn from(v: JsValue) -> Self { - match v.as_string().or_else(|| { - v.dyn_ref::().map(|e| { - format!( - "Error: {} - Cause: {}", - e.to_string(), - e.cause() - .as_string() - .or_else(|| { Some(e.to_string().into()) }) - .unwrap_or(String::from("N/A")) - ) - }) - }) { - Some(s) => Self::JsError(s), - None => Self::Internal(v), + if let Some(s) = v.as_string() { + return Self::JsError(s); } + // Preserve JS Error objects (and other non-string JsValues) as + // Internal so they survive roundtrips back to JS unchanged. + // This is important for workflow abort errors whose identity the + // engine checks with `instanceof Error` / `.message`. + Self::Internal(v) } } From 92d67784a2ff16c14918ea56b1696e13fc32d2ba Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Apr 2026 12:16:08 -0700 Subject: [PATCH 11/21] fix: resolve CI failures for clippy and panic-unwind - Use next_back() instead of last() on DoubleEndedIterator (clippy lint) - Use Closure::new instead of Closure::wrap to preserve UnwindSafe through the concrete closure type rather than erasing it via trait object cast --- test/src/workflow.rs | 2 +- worker/src/workflow.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 9707c2bd9..b7405b845 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -4,7 +4,7 @@ use worker::*; fn last_path_segment(req: &Request) -> Result { let url = req.url()?; url.path_segments() - .and_then(|s| s.last().map(String::from)) + .and_then(|mut s| s.next_back().map(String::from)) .ok_or_else(|| Error::RustError("missing path segment".into())) } diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 7f5729d5e..73421a87d 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -297,13 +297,13 @@ impl WorkflowStep { Fut: Future> + 'static, { let callback = Rc::new(AssertUnwindSafe(callback)); - wasm_bindgen::closure::Closure::wrap(Box::new(move || -> js_sys::Promise { + wasm_bindgen::closure::Closure::new(move || -> js_sys::Promise { let callback = callback.clone(); future_to_promise(AssertUnwindSafe(async move { let result = (callback.0)().await.map_err(JsValue::from)?; serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) })) - }) as Box js_sys::Promise>) + }) } /// Execute a named step. The callback's return value is persisted and From 7eaee7a834d285c534fe3840fdb2a9453d829e27 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Apr 2026 15:21:00 -0700 Subject: [PATCH 12/21] fix: wrap handler! async blocks in SendFuture to satisfy axum Send bound --- test/src/router.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/src/router.rs b/test/src/router.rs index 702310423..e8e4e9108 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -17,6 +17,8 @@ use axum::{ routing::{delete, get, head, options, patch, post, put}, Extension, }; +#[cfg(feature = "http")] +use worker::send::SendFuture; // Transform the argument into the correct form for the router. // For axum::Router: @@ -59,16 +61,16 @@ macro_rules! format_route ( #[cfg(feature = "http")] macro_rules! handler ( ($name:path) => { - |Extension(env): Extension, Extension(data): Extension, req: axum::extract::Request| async { + |Extension(env): Extension, Extension(data): Extension, req: axum::extract::Request| SendFuture::new(async { let resp = $name(req.try_into().expect("convert request"), env, data).await.expect("handler result"); Into::>::into(resp) - } + }) }; ($name:path, sync) => { - |Extension(env): Extension, Extension(data): Extension, req: axum::extract::Request| async { + |Extension(env): Extension, Extension(data): Extension, req: axum::extract::Request| SendFuture::new(async { let resp = $name(req.try_into().expect("convert request"), env, data).expect("handler result"); Into::>::into(resp) - } + }) }; ); #[cfg(not(feature = "http"))] From c89bee990dd9fbccc2c442db4b4c9dc14c032929 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 1 Apr 2026 20:32:29 -0500 Subject: [PATCH 13/21] pr comments * remove fetcher * remove result from id() * event_type -> type_ * workflowsleepduration serde_json::Value -> JsValue --- examples/workflow/src/lib.rs | 20 +++----- package-lock.json | 10 ---- test/src/workflow.rs | 46 +++++++---------- test/tests/workflow.spec.ts | 2 +- worker-macros/src/workflow.rs | 6 +-- worker/src/workflow.rs | 94 +++++++++++++++++++---------------- 6 files changed, 82 insertions(+), 96 deletions(-) diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index 5e745a200..823fd6dd9 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use worker::wasm_bindgen::JsValue; use worker::*; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -24,15 +25,10 @@ impl WorkflowEntrypoint for MyWorkflow { Self { env } } - async fn run( - &self, - event: WorkflowEvent, - step: WorkflowStep, - ) -> Result { + async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { console_log!("Workflow started with instance ID: {}", event.instance_id); - let params: MyParams = - serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + let params: MyParams = serde_wasm_bindgen::from_value(event.payload)?; let email_for_validation = params.email.clone(); step.do_with_config( @@ -111,7 +107,7 @@ impl WorkflowEntrypoint for MyWorkflow { steps_completed: 3, }; - Ok(serde_json::to_value(output).unwrap()) + Ok(serialize_as_object(&output)?) } } @@ -133,7 +129,7 @@ async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { .await?; Response::from_json(&serde_json::json!({ - "id": instance.id()?, + "id": instance.id(), "message": "Workflow created" })) } @@ -144,7 +140,7 @@ async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { let status = instance.status().await?; Response::from_json(&serde_json::json!({ - "id": instance.id()?, + "id": instance.id(), "status": format!("{:?}", status.status), "error": status.error, "output": status.output @@ -159,7 +155,7 @@ async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { instance.pause().await?; Response::from_json(&serde_json::json!({ - "id": instance.id()?, + "id": instance.id(), "message": "Workflow paused" })) } @@ -172,7 +168,7 @@ async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { instance.resume().await?; Response::from_json(&serde_json::json!({ - "id": instance.id()?, + "id": instance.id(), "message": "Workflow resumed" })) } diff --git a/package-lock.json b/package-lock.json index 971bddc53..bd318c8ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3444,16 +3444,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", diff --git a/test/src/workflow.rs b/test/src/workflow.rs index b7405b845..8d8c2ca83 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use worker::wasm_bindgen::JsValue; use worker::*; fn last_path_segment(req: &Request) -> Result { @@ -21,7 +22,7 @@ async fn get_workflow_instance( async fn create_workflow_no_params(env: &Env, binding: &str) -> Result { let workflow = env.workflow(binding)?; let instance = workflow.create(None::>).await?; - Response::from_json(&serde_json::json!({ "id": instance.id()? })) + Response::from_json(&serde_json::json!({ "id": instance.id() })) } fn status_response(status: InstanceStatus) -> Result { @@ -48,13 +49,8 @@ impl WorkflowEntrypoint for TestWorkflow { Self { env } } - async fn run( - &self, - event: WorkflowEvent, - step: WorkflowStep, - ) -> Result { - let params: TestParams = - serde_json::from_value(event.payload).map_err(|e| Error::RustError(e.to_string()))?; + async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { + let params: TestParams = serde_wasm_bindgen::from_value(event.payload)?; let value_for_validation = params.value.clone(); step.do_with_config( @@ -79,14 +75,14 @@ impl WorkflowEntrypoint for TestWorkflow { ) .await?; - let result = step + let result: serde_json::Value = step .do_("process", move || { let params = params.clone(); async move { Ok(serde_json::json!({ "processed": params.value })) } }) .await?; - Ok(result) + Ok(serialize_as_object(&result)?) } } @@ -102,7 +98,7 @@ async fn create_workflow_with_value(env: &Env, value: &str) -> Result })) .await?; - Response::from_json(&serde_json::json!({ "id": instance.id()? })) + Response::from_json(&serde_json::json!({ "id": instance.id() })) } pub async fn handle_workflow_create( @@ -142,25 +138,21 @@ impl WorkflowEntrypoint for EventWorkflow { Self { env } } - async fn run( - &self, - _event: WorkflowEvent, - step: WorkflowStep, - ) -> Result { + async fn run(&self, _event: WorkflowEvent, step: WorkflowStep) -> Result { let event = step .wait_for_event::( "wait-for-approval", WaitForEventOptions { - event_type: "approval".to_string(), + type_: "approval".to_string(), timeout: Some("30 seconds".to_string()), }, ) .await?; - Ok(serde_json::json!({ + Ok(serialize_as_object(&serde_json::json!({ "payload": event.payload, - "event_type": event.event_type, - })) + "type": event.type_, + }))?) } } @@ -204,13 +196,13 @@ impl WorkflowEntrypoint for LifecycleWorkflow { Self { env } } - async fn run( - &self, - _event: WorkflowEvent, - step: WorkflowStep, - ) -> Result { - step.sleep("long-sleep", "60 seconds").await?; - Ok(serde_json::json!({ "done": true })) + async fn run(&self, _event: WorkflowEvent, step: WorkflowStep) -> Result { + step.sleep( + "long-sleep", + WorkflowSleepDuration::new(60, WorkflowDuration::Seconds), + ) + .await?; + Ok(serialize_as_object(&serde_json::json!({ "done": true }))?) } } diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index 0aa496f3b..b471490e0 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -187,6 +187,6 @@ describe("workflow", () => { expect(status).toBe("Complete"); expect(output.payload).toEqual({ approved: true, reason: "looks good" }); - expect(output.event_type).toBe("approval"); + expect(output.type).toBe("approval"); }); }); diff --git a/worker-macros/src/workflow.rs b/worker-macros/src/workflow.rs index 12a06f9dd..657acef09 100644 --- a/worker-macros/src/workflow.rs +++ b/worker-macros/src/workflow.rs @@ -71,10 +71,8 @@ pub fn expand_macro(tokens: TokenStream) -> syn::Result { let event = ::worker::WorkflowEvent::from_js(event) .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string()))?; let step = ::worker::WorkflowStep::from(step); - let result = ::run(static_self, event, step).await - .map_err(::worker::wasm_bindgen::JsValue::from)?; - ::worker::serialize_as_object(&result) - .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string())) + ::run(static_self, event, step).await + .map_err(::worker::wasm_bindgen::JsValue::from) })) } } diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 73421a87d..0b3c7cca4 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -18,7 +18,10 @@ use crate::env::EnvBinding; use crate::send::SendFuture; use crate::Result; -#[doc(hidden)] +/// Serialize a value to a JS object, ensuring maps are serialized as plain objects. +/// +/// This is useful when returning values from [`WorkflowEntrypoint::run`] that +/// need to be plain JS objects rather than `Map` instances. pub fn serialize_as_object( value: &T, ) -> std::result::Result { @@ -101,10 +104,7 @@ impl EnvBinding for Workflow { fn get(val: JsValue) -> Result { let obj = Object::from(val); let constructor_name = obj.constructor().name(); - if constructor_name == Self::TYPE_NAME - || constructor_name == "WorkflowImpl" - || constructor_name == "Fetcher" - { + if constructor_name == Self::TYPE_NAME || constructor_name == "WorkflowImpl" { Ok(Self { inner: obj.unchecked_into(), }) @@ -196,8 +196,9 @@ impl WorkflowInstance { } /// The unique ID of this workflow instance. - pub fn id(&self) -> Result { + pub fn id(&self) -> String { get_string_property(self.inner.as_ref(), "id") + .expect("WorkflowInstance always has an id property") } /// Pause the workflow instance. @@ -231,17 +232,14 @@ impl WorkflowInstance { } /// Send an event to the workflow instance to trigger `step.wait_for_event()` calls. - pub async fn send_event(&self, event_type: &str, payload: T) -> Result<()> { + pub async fn send_event(&self, type_: &str, payload: T) -> Result<()> { #[derive(Serialize)] struct SendEventPayload<'a, P: Serialize> { #[serde(rename = "type")] - event_type: &'a str, + type_: &'a str, payload: P, } - let event = serde_wasm_bindgen::to_value(&SendEventPayload { - event_type, - payload, - })?; + let event = serde_wasm_bindgen::to_value(&SendEventPayload { type_, payload })?; SendFuture::new(self.inner.send_event(event)).await?; Ok(()) } @@ -340,8 +338,12 @@ impl WorkflowStep { } /// Sleep for a specified duration (e.g., "1 minute", "5 seconds"). - pub async fn sleep(&self, name: &str, duration: impl Into) -> Result<()> { - let duration_js = duration.into().to_js_value(); + pub async fn sleep( + &self, + name: &str, + duration: impl Into, + ) -> Result<()> { + let duration_js = duration.into().0; SendFuture::new(self.0.sleep(name, duration_js)).await?; Ok(()) } @@ -403,7 +405,7 @@ pub enum Backoff { #[derive(Debug, Clone, Serialize)] pub struct WaitForEventOptions { #[serde(rename = "type")] - pub event_type: String, + pub type_: String, #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, } @@ -413,7 +415,7 @@ pub struct WaitForEventOptions { pub struct WorkflowStepEvent { pub payload: T, pub timestamp: crate::Date, - pub event_type: String, + pub type_: String, } impl WorkflowStepEvent { @@ -421,60 +423,72 @@ impl WorkflowStepEvent { Ok(Self { payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, timestamp: get_timestamp_property(&value, "timestamp")?, - event_type: get_string_property(&value, "type")?, + type_: get_string_property(&value, "type")?, }) } } /// The event passed to a workflow's run method. #[derive(Debug, Clone)] -pub struct WorkflowEvent { - pub payload: T, +pub struct WorkflowEvent { + pub payload: JsValue, pub timestamp: crate::Date, pub instance_id: String, } -impl WorkflowEvent { +impl WorkflowEvent { pub fn from_js(value: JsValue) -> Result { Ok(Self { - payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, + payload: get_property(&value, "payload")?, timestamp: get_timestamp_property(&value, "timestamp")?, instance_id: get_string_property(&value, "instanceId")?, }) } } -/// Duration type for workflow sleep operations. -#[derive(Debug, Clone)] +/// Unit of time for workflow sleep durations. +#[derive(Debug, Clone, Copy)] pub enum WorkflowDuration { - Milliseconds(u64), - String(String), + Seconds, + Minutes, + Hours, + Days, } -impl WorkflowDuration { - fn to_js_value(&self) -> JsValue { - match self { - Self::Milliseconds(ms) => JsValue::from_f64(*ms as f64), - Self::String(s) => JsValue::from_str(s), - } +/// A typed duration for workflow sleep operations. +/// +/// Wraps a JS-compatible value representing the sleep duration. +#[derive(Debug, Clone)] +pub struct WorkflowSleepDuration(JsValue); + +impl WorkflowSleepDuration { + /// Create a new sleep duration with the given amount and unit. + pub fn new(amount: u32, unit: WorkflowDuration) -> Self { + let unit_str = match unit { + WorkflowDuration::Seconds => "seconds", + WorkflowDuration::Minutes => "minutes", + WorkflowDuration::Hours => "hours", + WorkflowDuration::Days => "days", + }; + Self(JsValue::from_str(&format!("{amount} {unit_str}"))) } } -impl From<&str> for WorkflowDuration { +impl From<&str> for WorkflowSleepDuration { fn from(s: &str) -> Self { - Self::String(s.to_string()) + Self(JsValue::from_str(s)) } } -impl From for WorkflowDuration { +impl From for WorkflowSleepDuration { fn from(s: String) -> Self { - Self::String(s) + Self(JsValue::from_str(&s)) } } -impl From for WorkflowDuration { +impl From for WorkflowSleepDuration { fn from(d: std::time::Duration) -> Self { - Self::Milliseconds(d.as_millis() as u64) + Self(JsValue::from_f64(d.as_millis() as f64)) } } @@ -524,9 +538,5 @@ pub trait HasWorkflowAttribute {} pub trait WorkflowEntrypoint: HasWorkflowAttribute { fn new(ctx: crate::Context, env: crate::Env) -> Self; - async fn run( - &self, - event: WorkflowEvent, - step: WorkflowStep, - ) -> Result; + async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result; } From 51c2b1d0c4414d94a18ac4a074a34c6929d677fd Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 1 Apr 2026 20:43:38 -0500 Subject: [PATCH 14/21] use WorkflowSleepDuration everywhere --- examples/workflow/src/lib.rs | 6 ++-- test/src/workflow.rs | 4 +-- worker/src/workflow.rs | 65 +++++++++++++++++++++++++++--------- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index 823fd6dd9..e74440cf1 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -36,7 +36,7 @@ impl WorkflowEntrypoint for MyWorkflow { StepConfig { retries: Some(RetryConfig { limit: 3, - delay: "1 second".to_string(), + delay: "1 second".into(), backoff: None, }), timeout: None, @@ -79,10 +79,10 @@ impl WorkflowEntrypoint for MyWorkflow { StepConfig { retries: Some(RetryConfig { limit: 3, - delay: "5 seconds".to_string(), + delay: "5 seconds".into(), backoff: Some(Backoff::Exponential), }), - timeout: Some("1 minute".to_string()), + timeout: Some("1 minute".into()), }, move || { let email = email_for_step3.clone(); diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 8d8c2ca83..3b0795c91 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -58,7 +58,7 @@ impl WorkflowEntrypoint for TestWorkflow { StepConfig { retries: Some(RetryConfig { limit: 2, - delay: "1 second".to_string(), + delay: "1 second".into(), backoff: None, }), timeout: None, @@ -144,7 +144,7 @@ impl WorkflowEntrypoint for EventWorkflow { "wait-for-approval", WaitForEventOptions { type_: "approval".to_string(), - timeout: Some("30 seconds".to_string()), + timeout: Some("30 seconds".into()), }, ) .await?; diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 0b3c7cca4..1b3438edc 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -173,9 +173,9 @@ impl Default for CreateOptions { #[serde(rename_all = "camelCase")] pub struct RetentionOptions { #[serde(skip_serializing_if = "Option::is_none")] - pub success_retention: Option, + pub success_retention: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub error_retention: Option, + pub error_retention: Option, } /// A handle to a workflow instance. @@ -343,7 +343,7 @@ impl WorkflowStep { name: &str, duration: impl Into, ) -> Result<()> { - let duration_js = duration.into().0; + let duration_js = duration.into().to_js_value(); SendFuture::new(self.0.sleep(name, duration_js)).await?; Ok(()) } @@ -380,14 +380,14 @@ pub struct StepConfig { #[serde(skip_serializing_if = "Option::is_none")] pub retries: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, + pub timeout: Option, } /// Retry configuration for a workflow step. #[derive(Debug, Clone, Serialize)] pub struct RetryConfig { pub limit: u32, - pub delay: String, + pub delay: WorkflowSleepDuration, #[serde(skip_serializing_if = "Option::is_none")] pub backoff: Option, } @@ -407,7 +407,7 @@ pub struct WaitForEventOptions { #[serde(rename = "type")] pub type_: String, #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, + pub timeout: Option, } /// An event received from `wait_for_event`. @@ -446,49 +446,84 @@ impl WorkflowEvent { } } -/// Unit of time for workflow sleep durations. +/// Unit of time for workflow durations. #[derive(Debug, Clone, Copy)] pub enum WorkflowDuration { Seconds, Minutes, Hours, Days, + Weeks, + Months, + Years, } -/// A typed duration for workflow sleep operations. +/// A typed duration used throughout the Workflows API for sleep, timeout, +/// retry delay, and retention fields. /// -/// Wraps a JS-compatible value representing the sleep duration. +/// Corresponds to the `WorkflowSleepDuration` type in the Workers runtime, +/// which accepts either a string like `"5 seconds"` or a number of milliseconds. #[derive(Debug, Clone)] -pub struct WorkflowSleepDuration(JsValue); +enum WorkflowSleepDurationInner { + Text(String), + Millis(f64), +} + +#[derive(Debug, Clone)] +pub struct WorkflowSleepDuration(WorkflowSleepDurationInner); impl WorkflowSleepDuration { - /// Create a new sleep duration with the given amount and unit. + /// Create a new duration with the given amount and unit. pub fn new(amount: u32, unit: WorkflowDuration) -> Self { let unit_str = match unit { WorkflowDuration::Seconds => "seconds", WorkflowDuration::Minutes => "minutes", WorkflowDuration::Hours => "hours", WorkflowDuration::Days => "days", + WorkflowDuration::Weeks => "weeks", + WorkflowDuration::Months => "months", + WorkflowDuration::Years => "years", }; - Self(JsValue::from_str(&format!("{amount} {unit_str}"))) + Self(WorkflowSleepDurationInner::Text(format!( + "{amount} {unit_str}" + ))) + } + + fn to_js_value(&self) -> JsValue { + match &self.0 { + WorkflowSleepDurationInner::Text(s) => JsValue::from_str(s), + WorkflowSleepDurationInner::Millis(ms) => JsValue::from_f64(*ms), + } + } +} + +impl Serialize for WorkflowSleepDuration { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + match &self.0 { + WorkflowSleepDurationInner::Text(s) => serializer.serialize_str(s), + WorkflowSleepDurationInner::Millis(ms) => serializer.serialize_f64(*ms), + } } } impl From<&str> for WorkflowSleepDuration { fn from(s: &str) -> Self { - Self(JsValue::from_str(s)) + Self(WorkflowSleepDurationInner::Text(s.to_string())) } } impl From for WorkflowSleepDuration { fn from(s: String) -> Self { - Self(JsValue::from_str(&s)) + Self(WorkflowSleepDurationInner::Text(s)) } } impl From for WorkflowSleepDuration { fn from(d: std::time::Duration) -> Self { - Self(JsValue::from_f64(d.as_millis() as f64)) + Self(WorkflowSleepDurationInner::Millis(d.as_millis() as f64)) } } From 834db2cae9f00eb1da2b8f5c382276594dfe09de Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 1 Apr 2026 20:47:26 -0500 Subject: [PATCH 15/21] thread through WorkflowStepContext noticed while looking at the workerd types --- examples/workflow/src/lib.rs | 6 +++--- test/src/workflow.rs | 4 ++-- worker/src/workflow.rs | 25 +++++++++++++++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index e74440cf1..6a840e4dc 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -41,7 +41,7 @@ impl WorkflowEntrypoint for MyWorkflow { }), timeout: None, }, - move || { + move |_ctx| { let email = email_for_validation.clone(); async move { if !email.contains('@') { @@ -55,7 +55,7 @@ impl WorkflowEntrypoint for MyWorkflow { let name_for_step1 = params.name.clone(); let step1_result = step - .do_("initial-processing", move || { + .do_("initial-processing", move |_ctx| { let name = name_for_step1.clone(); async move { console_log!("Processing for user: {}", name); @@ -84,7 +84,7 @@ impl WorkflowEntrypoint for MyWorkflow { }), timeout: Some("1 minute".into()), }, - move || { + move |_ctx| { let email = email_for_step3.clone(); async move { console_log!("Sending notification to: {}", email); diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 3b0795c91..ba849b438 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -63,7 +63,7 @@ impl WorkflowEntrypoint for TestWorkflow { }), timeout: None, }, - move || { + move |_ctx| { let value = value_for_validation.clone(); async move { if value.is_empty() { @@ -76,7 +76,7 @@ impl WorkflowEntrypoint for TestWorkflow { .await?; let result: serde_json::Value = step - .do_("process", move || { + .do_("process", move |_ctx| { let params = params.clone(); async move { Ok(serde_json::json!({ "processed": params.value })) } }) diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 1b3438edc..ce698f51c 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -277,6 +277,13 @@ pub struct InstanceError { pub message: String, } +/// Context passed to step callbacks, providing information about the current execution attempt. +#[derive(Debug, Clone, Copy)] +pub struct WorkflowStepContext { + /// The current retry attempt number (starts at 1). + pub attempt: u32, +} + /// Provides methods for executing durable workflow steps. #[derive(Debug)] pub struct WorkflowStep(WorkflowStepSys); @@ -288,17 +295,23 @@ unsafe impl Sync for WorkflowStep {} impl WorkflowStep { fn wrap_callback( callback: F, - ) -> wasm_bindgen::closure::Closure js_sys::Promise> + ) -> wasm_bindgen::closure::Closure js_sys::Promise> where T: Serialize + 'static, - F: Fn() -> Fut + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, Fut: Future> + 'static, { let callback = Rc::new(AssertUnwindSafe(callback)); - wasm_bindgen::closure::Closure::new(move || -> js_sys::Promise { + wasm_bindgen::closure::Closure::new(move |ctx: JsValue| -> js_sys::Promise { let callback = callback.clone(); + let attempt = Reflect::get(&ctx, &JsValue::from_str("attempt")) + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(1.0) as u32; future_to_promise(AssertUnwindSafe(async move { - let result = (callback.0)().await.map_err(JsValue::from)?; + let result = (callback.0)(WorkflowStepContext { attempt }) + .await + .map_err(JsValue::from)?; serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) })) }) @@ -309,7 +322,7 @@ impl WorkflowStep { pub async fn do_(&self, name: &str, callback: F) -> Result where T: Serialize + DeserializeOwned + 'static, - F: Fn() -> Fut + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, Fut: Future> + 'static, { let closure = Self::wrap_callback(callback); @@ -327,7 +340,7 @@ impl WorkflowStep { ) -> Result where T: Serialize + DeserializeOwned + 'static, - F: Fn() -> Fut + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, Fut: Future> + 'static, { let config_js = serde_wasm_bindgen::to_value(&config)?; From f2f4926de55ba9053005ade6c9ed9e7dd13003dd Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Tue, 21 Apr 2026 16:07:19 -0500 Subject: [PATCH 16/21] feat(workflow): support step context and ReadableStream returns Track the 2026-04-21 Workflows runtime update: - expand WorkflowStepContext with step.name, step.count, and a resolved config (timeout, retries) alongside attempt - add do_stream / do_stream_with_config for returning a ReadableStream directly, bypassing the 1 MiB serialized-output limit - bump workflow-example compatibility_date to 2026-04-21 https://developers.cloudflare.com/changelog/post/2026-04-21-step-context-and-readable-streams/ --- examples/workflow/src/lib.rs | 8 +- examples/workflow/wrangler.toml | 2 +- test/src/workflow.rs | 9 +- worker/src/workflow.rs | 224 ++++++++++++++++++++++++++++++-- 4 files changed, 228 insertions(+), 15 deletions(-) diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index 6a840e4dc..2ddbb8916 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -41,9 +41,15 @@ impl WorkflowEntrypoint for MyWorkflow { }), timeout: None, }, - move |_ctx| { + move |ctx| { let email = email_for_validation.clone(); async move { + console_log!( + "step '{}' attempt {}/{}", + ctx.step.name, + ctx.attempt, + ctx.config.retries.limit + ); if !email.contains('@') { return Err(NonRetryableError::new("invalid email address").into()); } diff --git a/examples/workflow/wrangler.toml b/examples/workflow/wrangler.toml index b20e6ef7c..5c0671168 100644 --- a/examples/workflow/wrangler.toml +++ b/examples/workflow/wrangler.toml @@ -1,6 +1,6 @@ name = "workflow-example" main = "build/worker/shim.mjs" -compatibility_date = "2024-10-22" +compatibility_date = "2026-04-21" [build] # For development: use local worker-build binary diff --git a/test/src/workflow.rs b/test/src/workflow.rs index ba849b438..35b14bed9 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -63,13 +63,18 @@ impl WorkflowEntrypoint for TestWorkflow { }), timeout: None, }, - move |_ctx| { + move |ctx| { let value = value_for_validation.clone(); async move { if value.is_empty() { return Err(NonRetryableError::new("value must not be empty").into()); } - Ok(serde_json::json!({ "valid": true })) + Ok(serde_json::json!({ + "valid": true, + "step_name": ctx.step.name, + "attempt": ctx.attempt, + "count": ctx.step.count, + })) } }, ) diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index ce698f51c..c2b03218a 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -44,6 +44,10 @@ fn get_timestamp_property(target: &JsValue, name: &str) -> Result { Ok(crate::Date::from(js_sys::Date::from(val))) } +fn get_f64_property(target: &JsValue, name: &str) -> Option { + get_property(target, name).ok().and_then(|v| v.as_f64()) +} + /// A Workflow binding for creating and managing workflow instances. #[derive(Debug, Clone)] pub struct Workflow { @@ -277,11 +281,134 @@ pub struct InstanceError { pub message: String, } -/// Context passed to step callbacks, providing information about the current execution attempt. -#[derive(Debug, Clone, Copy)] +/// Context passed to step callbacks with information about the current step invocation. +#[derive(Debug, Clone)] pub struct WorkflowStepContext { + /// Identity of the step being executed. + pub step: WorkflowStepInfo, /// The current retry attempt number (starts at 1). pub attempt: u32, + /// The fully resolved step configuration, with runtime defaults applied. + pub config: ResolvedStepConfig, +} + +/// Identity of a step within a workflow run. +#[derive(Debug, Clone)] +pub struct WorkflowStepInfo { + /// The step's name, as passed to `step.do()` / `step.do_with_config()`. + pub name: String, + /// Number of times this step has been invoked in the current run, starting at 1. + /// + /// Useful for disambiguating steps inside loops. + pub count: u32, +} + +impl WorkflowStepInfo { + fn from_js(val: &JsValue) -> Self { + Self { + name: get_property(val, "name") + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(), + count: get_f64_property(val, "count").unwrap_or(1.0) as u32, + } + } +} + +impl Default for WorkflowStepInfo { + fn default() -> Self { + Self { + name: String::new(), + count: 1, + } + } +} + +impl WorkflowStepContext { + fn from_js(ctx: &JsValue) -> Self { + Self { + step: get_property(ctx, "step") + .ok() + .map(|v| WorkflowStepInfo::from_js(&v)) + .unwrap_or_default(), + attempt: get_f64_property(ctx, "attempt").unwrap_or(1.0) as u32, + config: get_property(ctx, "config") + .ok() + .map(|v| ResolvedStepConfig::from_js(&v)) + .unwrap_or_default(), + } + } +} + +/// A step configuration with runtime defaults applied. +#[derive(Debug, Clone)] +pub struct ResolvedStepConfig { + pub timeout: WorkflowSleepDuration, + pub retries: ResolvedRetryConfig, +} + +impl ResolvedStepConfig { + fn from_js(val: &JsValue) -> Self { + let defaults = Self::default(); + let timeout = get_property(val, "timeout") + .ok() + .and_then(|v| WorkflowSleepDuration::from_js_value(&v)) + .unwrap_or(defaults.timeout); + let retries = get_property(val, "retries") + .ok() + .map(|v| ResolvedRetryConfig::from_js(&v)) + .unwrap_or(defaults.retries); + Self { timeout, retries } + } +} + +impl Default for ResolvedStepConfig { + fn default() -> Self { + Self { + timeout: WorkflowSleepDuration::text("10 minutes"), + retries: ResolvedRetryConfig::default(), + } + } +} + +/// A retry configuration with runtime defaults applied. +#[derive(Debug, Clone)] +pub struct ResolvedRetryConfig { + pub limit: u32, + pub delay: WorkflowSleepDuration, + pub backoff: Backoff, +} + +impl ResolvedRetryConfig { + fn from_js(val: &JsValue) -> Self { + let defaults = Self::default(); + let limit = get_f64_property(val, "limit") + .map(|v| v as u32) + .unwrap_or(defaults.limit); + let delay = get_property(val, "delay") + .ok() + .and_then(|v| WorkflowSleepDuration::from_js_value(&v)) + .unwrap_or(defaults.delay); + let backoff = get_property(val, "backoff") + .ok() + .and_then(|v| serde_wasm_bindgen::from_value(v).ok()) + .unwrap_or(defaults.backoff); + Self { + limit, + delay, + backoff, + } + } +} + +impl Default for ResolvedRetryConfig { + fn default() -> Self { + Self { + limit: 5, + delay: WorkflowSleepDuration::text("1 second"), + backoff: Backoff::Exponential, + } + } } /// Provides methods for executing durable workflow steps. @@ -300,23 +427,46 @@ impl WorkflowStep { T: Serialize + 'static, F: Fn(WorkflowStepContext) -> Fut + 'static, Fut: Future> + 'static, + { + Self::wrap_callback_raw(move |ctx| { + let fut = callback(ctx); + async move { + let result = fut.await?; + serialize_as_object(&result).map_err(|e| crate::Error::from(e.to_string())) + } + }) + } + + fn wrap_callback_raw( + callback: F, + ) -> wasm_bindgen::closure::Closure js_sys::Promise> + where + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, { let callback = Rc::new(AssertUnwindSafe(callback)); wasm_bindgen::closure::Closure::new(move |ctx: JsValue| -> js_sys::Promise { let callback = callback.clone(); - let attempt = Reflect::get(&ctx, &JsValue::from_str("attempt")) - .ok() - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) as u32; + let context = WorkflowStepContext::from_js(&ctx); future_to_promise(AssertUnwindSafe(async move { - let result = (callback.0)(WorkflowStepContext { attempt }) - .await - .map_err(JsValue::from)?; - serialize_as_object(&result).map_err(|e| JsValue::from_str(&e.to_string())) + (callback.0)(context).await.map_err(JsValue::from) })) }) } + fn wrap_stream_callback( + callback: F, + ) -> wasm_bindgen::closure::Closure js_sys::Promise> + where + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + { + Self::wrap_callback_raw(move |ctx| { + let fut = callback(ctx); + async move { fut.await.map(JsValue::from) } + }) + } + /// Execute a named step. The callback's return value is persisted and /// returned without re-executing on replay. pub async fn do_(&self, name: &str, callback: F) -> Result @@ -350,6 +500,45 @@ impl WorkflowStep { Ok(serde_wasm_bindgen::from_value(result)?) } + /// Execute a named step whose return value is a `ReadableStream`. + /// + /// Stream return values are not subject to the 1 MiB payload limit that + /// applies to serialized step outputs, making this suitable for passing + /// large bodies (for example, an R2 object body) between steps. + pub async fn do_stream( + &self, + name: &str, + callback: F, + ) -> Result + where + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + { + let closure = Self::wrap_stream_callback(callback); + let js_fn = closure.as_ref().unchecked_ref::(); + let result = SendFuture::new(self.0.do_(name, js_fn)).await?; + Ok(result.unchecked_into()) + } + + /// Execute a named step whose return value is a `ReadableStream`, with + /// retry and timeout configuration. + pub async fn do_stream_with_config( + &self, + name: &str, + config: StepConfig, + callback: F, + ) -> Result + where + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + { + let config_js = serde_wasm_bindgen::to_value(&config)?; + let closure = Self::wrap_stream_callback(callback); + let js_fn = closure.as_ref().unchecked_ref::(); + let result = SendFuture::new(self.0.do_with_config(name, config_js, js_fn)).await?; + Ok(result.unchecked_into()) + } + /// Sleep for a specified duration (e.g., "1 minute", "5 seconds"). pub async fn sleep( &self, @@ -406,7 +595,7 @@ pub struct RetryConfig { } /// Backoff strategy for retries. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Backoff { Constant, @@ -502,12 +691,25 @@ impl WorkflowSleepDuration { ))) } + fn text(s: &str) -> Self { + Self(WorkflowSleepDurationInner::Text(s.to_string())) + } + fn to_js_value(&self) -> JsValue { match &self.0 { WorkflowSleepDurationInner::Text(s) => JsValue::from_str(s), WorkflowSleepDurationInner::Millis(ms) => JsValue::from_f64(*ms), } } + + fn from_js_value(val: &JsValue) -> Option { + if let Some(s) = val.as_string() { + Some(Self(WorkflowSleepDurationInner::Text(s))) + } else { + val.as_f64() + .map(|n| Self(WorkflowSleepDurationInner::Millis(n))) + } + } } impl Serialize for WorkflowSleepDuration { From b15475494bb9580ae59d9155e19be1026455d7af Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 26 Apr 2026 21:17:11 -0500 Subject: [PATCH 17/21] tiny comment cleanup --- worker/src/workflow.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index c2b03218a..b93e03522 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -18,10 +18,10 @@ use crate::env::EnvBinding; use crate::send::SendFuture; use crate::Result; -/// Serialize a value to a JS object, ensuring maps are serialized as plain objects. +/// Serialize a value to a JS object, with maps serialized as plain objects. /// -/// This is useful when returning values from [`WorkflowEntrypoint::run`] that -/// need to be plain JS objects rather than `Map` instances. +/// Use this for return values from [`WorkflowEntrypoint::run`] that must be +/// plain JS objects rather than `Map` instances. pub fn serialize_as_object( value: &T, ) -> std::result::Result { @@ -297,7 +297,7 @@ pub struct WorkflowStepContext { pub struct WorkflowStepInfo { /// The step's name, as passed to `step.do()` / `step.do_with_config()`. pub name: String, - /// Number of times this step has been invoked in the current run, starting at 1. + /// Number of times this step has been invoked in the current run. Starts at 1. /// /// Useful for disambiguating steps inside loops. pub count: u32, @@ -502,9 +502,9 @@ impl WorkflowStep { /// Execute a named step whose return value is a `ReadableStream`. /// - /// Stream return values are not subject to the 1 MiB payload limit that - /// applies to serialized step outputs, making this suitable for passing - /// large bodies (for example, an R2 object body) between steps. + /// Unlike serialized step outputs, stream return values aren't subject to + /// the 1 MiB payload limit, so this works for passing large bodies between + /// steps (for example, an R2 object body). pub async fn do_stream( &self, name: &str, From 30f2d4e2a21774f592dc7f911e41eb4bce1a3cd9 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 26 Apr 2026 22:14:37 -0500 Subject: [PATCH 18/21] refactor(workflow): typed Input/Output, fix serializer round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit  WorkflowEntrypoint now has `type Input: DeserializeOwned` and `type Output: Serialize`, and WorkflowEvent is generic over the input. The macro deserializes on entry and serializes on exit, so workflows stop calling `serde_wasm_bindgen::from_value` and `serialize_as_object` by hand inside run(). `Workflow::create` is split into `create(CreateOptions)` and `create_default()`. The no-params path no longer needs the `None::>` turbofish. The JS serializer used at the runtime boundary is centralized with two flags. `serialize_missing_as_null(true)` stops `params: None` from round-tripping through Miniflare's `params = {}` destructuring default, which would otherwise hand us a `{}` payload that can't deserialize as `()` for `Input = ()` workflows. `serialize_maps_as_objects(true)` emits `serde_json::Value::Object` and `HashMap` payloads as plain objects instead of JS Maps, which fail struct deserialization on the other side. `serde_json` and `serde_wasm_bindgen` are re-exported behind the workflow feature now, so non-workflow users don't pick them up implicitly. Macro-internal items (`serialize_as_object`, `WorkflowEvent::from_js`, `From for WorkflowStep`) are `#[doc(hidden)]`. Test workflows are rewritten against the new typed surface: typed step returns, `wait_for_event::`, and run() outputs composed from typed step outputs. --- examples/workflow/src/lib.rs | 18 ++--- package-lock.json | 10 +++ test/src/workflow.rs | 136 +++++++++++++++++++++++----------- test/tests/workflow.spec.ts | 12 ++- worker-macros/src/workflow.rs | 27 ++++--- worker/src/lib.rs | 2 + worker/src/workflow.rs | 124 +++++++++++++++++++++---------- 7 files changed, 219 insertions(+), 110 deletions(-) diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index 2ddbb8916..ee4393fe5 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use worker::wasm_bindgen::JsValue; use worker::*; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -21,14 +20,17 @@ pub struct MyWorkflow { } impl WorkflowEntrypoint for MyWorkflow { + type Input = MyParams; + type Output = MyOutput; + fn new(_ctx: Context, env: Env) -> Self { Self { env } } - async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { + async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { console_log!("Workflow started with instance ID: {}", event.instance_id); - let params: MyParams = serde_wasm_bindgen::from_value(event.payload)?; + let params = event.payload; let email_for_validation = params.email.clone(); step.do_with_config( @@ -108,12 +110,10 @@ impl WorkflowEntrypoint for MyWorkflow { console_log!("Step 3 completed: {:?}", notification_result); - let output = MyOutput { + Ok(MyOutput { message: format!("Workflow completed for {}", params.name), steps_completed: 3, - }; - - Ok(serialize_as_object(&output)?) + }) } } @@ -128,10 +128,10 @@ async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { let params: MyParams = req.json().await?; let instance = workflow - .create(Some(CreateOptions { + .create(CreateOptions { params: Some(params), ..Default::default() - })) + }) .await?; Response::from_json(&serde_json::json!({ diff --git a/package-lock.json b/package-lock.json index 588d008cd..623087922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3479,6 +3479,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", diff --git a/test/src/workflow.rs b/test/src/workflow.rs index 35b14bed9..828e4842b 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use worker::wasm_bindgen::JsValue; use worker::*; fn last_path_segment(req: &Request) -> Result { @@ -21,7 +20,7 @@ async fn get_workflow_instance( async fn create_workflow_no_params(env: &Env, binding: &str) -> Result { let workflow = env.workflow(binding)?; - let instance = workflow.create(None::>).await?; + let instance = workflow.create_default().await?; Response::from_json(&serde_json::json!({ "id": instance.id() })) } @@ -38,6 +37,18 @@ pub struct TestParams { pub value: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationOutcome { + pub valid: bool, + pub attempt: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestOutput { + pub processed: String, + pub validation: ValidationOutcome, +} + #[workflow] pub struct TestWorkflow { #[allow(dead_code)] @@ -45,49 +56,58 @@ pub struct TestWorkflow { } impl WorkflowEntrypoint for TestWorkflow { + type Input = TestParams; + type Output = TestOutput; + fn new(_ctx: Context, env: Env) -> Self { Self { env } } - async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { - let params: TestParams = serde_wasm_bindgen::from_value(event.payload)?; - - let value_for_validation = params.value.clone(); - step.do_with_config( - "validate", - StepConfig { - retries: Some(RetryConfig { - limit: 2, - delay: "1 second".into(), - backoff: None, - }), - timeout: None, - }, - move |ctx| { - let value = value_for_validation.clone(); - async move { - if value.is_empty() { - return Err(NonRetryableError::new("value must not be empty").into()); + async fn run( + &self, + event: WorkflowEvent, + step: WorkflowStep, + ) -> Result { + let value = event.payload.value; + + let value_for_validation = value.clone(); + let validation: ValidationOutcome = step + .do_with_config( + "validate", + StepConfig { + retries: Some(RetryConfig { + limit: 2, + delay: "1 second".into(), + backoff: None, + }), + timeout: None, + }, + move |ctx| { + let value = value_for_validation.clone(); + async move { + if value.is_empty() { + return Err(NonRetryableError::new("value must not be empty").into()); + } + Ok(ValidationOutcome { + valid: true, + attempt: ctx.attempt, + }) } - Ok(serde_json::json!({ - "valid": true, - "step_name": ctx.step.name, - "attempt": ctx.attempt, - "count": ctx.step.count, - })) - } - }, - ) - .await?; + }, + ) + .await?; - let result: serde_json::Value = step + let processed: String = step .do_("process", move |_ctx| { - let params = params.clone(); - async move { Ok(serde_json::json!({ "processed": params.value })) } + let value = value.clone(); + async move { Ok(value) } }) .await?; - Ok(serialize_as_object(&result)?) + Ok(TestOutput { + processed, + validation, + }) } } @@ -97,10 +117,10 @@ async fn create_workflow_with_value(env: &Env, value: &str) -> Result value: value.to_string(), }; let instance = workflow - .create(Some(CreateOptions { + .create(CreateOptions { params: Some(params), ..Default::default() - })) + }) .await?; Response::from_json(&serde_json::json!({ "id": instance.id() })) @@ -132,6 +152,20 @@ pub async fn handle_workflow_status( status_response(status) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalEvent { + pub approved: bool, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalResult { + pub approved: bool, + pub reason: String, + #[serde(rename = "type")] + pub event_type: String, +} + #[workflow] pub struct EventWorkflow { #[allow(dead_code)] @@ -139,13 +173,16 @@ pub struct EventWorkflow { } impl WorkflowEntrypoint for EventWorkflow { + type Input = (); + type Output = ApprovalResult; + fn new(_ctx: Context, env: Env) -> Self { Self { env } } - async fn run(&self, _event: WorkflowEvent, step: WorkflowStep) -> Result { + async fn run(&self, _event: WorkflowEvent<()>, step: WorkflowStep) -> Result { let event = step - .wait_for_event::( + .wait_for_event::( "wait-for-approval", WaitForEventOptions { type_: "approval".to_string(), @@ -154,10 +191,11 @@ impl WorkflowEntrypoint for EventWorkflow { ) .await?; - Ok(serialize_as_object(&serde_json::json!({ - "payload": event.payload, - "type": event.type_, - }))?) + Ok(ApprovalResult { + approved: event.payload.approved, + reason: event.payload.reason, + event_type: event.type_, + }) } } @@ -190,6 +228,11 @@ pub async fn handle_event_workflow_status( status_response(status) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifecycleResult { + pub completed: bool, +} + #[workflow] pub struct LifecycleWorkflow { #[allow(dead_code)] @@ -197,17 +240,20 @@ pub struct LifecycleWorkflow { } impl WorkflowEntrypoint for LifecycleWorkflow { + type Input = (); + type Output = LifecycleResult; + fn new(_ctx: Context, env: Env) -> Self { Self { env } } - async fn run(&self, _event: WorkflowEvent, step: WorkflowStep) -> Result { + async fn run(&self, _event: WorkflowEvent<()>, step: WorkflowStep) -> Result { step.sleep( "long-sleep", WorkflowSleepDuration::new(60, WorkflowDuration::Seconds), ) .await?; - Ok(serialize_as_object(&serde_json::json!({ "done": true }))?) + Ok(LifecycleResult { completed: true }) } } diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index b471490e0..d937a4c7d 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -32,7 +32,10 @@ describe("workflow", () => { } expect(status).toBe("Complete"); - expect(output).toEqual({ processed: "hello" }); + expect(output).toEqual({ + processed: "hello", + validation: { valid: true, attempt: 1 }, + }); }); test("non-retryable error stops workflow immediately", async () => { @@ -186,7 +189,10 @@ describe("workflow", () => { } expect(status).toBe("Complete"); - expect(output.payload).toEqual({ approved: true, reason: "looks good" }); - expect(output.type).toBe("approval"); + expect(output).toEqual({ + approved: true, + reason: "looks good", + type: "approval", + }); }); }); diff --git a/worker-macros/src/workflow.rs b/worker-macros/src/workflow.rs index 657acef09..414839711 100644 --- a/worker-macros/src/workflow.rs +++ b/worker-macros/src/workflow.rs @@ -18,7 +18,6 @@ pub fn expand_macro(tokens: TokenStream) -> syn::Result { let target_name = &target.ident; let marker_fn_name = format_ident!("__wf_{}", target_name); let marker_js_name = format!("__wf_{target_name}"); - let target_name_str = target_name.to_string(); Ok(quote! { #target @@ -30,11 +29,11 @@ pub fn expand_macro(tokens: TokenStream) -> syn::Result { #[allow(unused_imports)] use ::worker::WorkflowEntrypoint; + // Marker export read by `worker-build` to detect workflow classes + // in the generated `index.js`. Only the function name matters. #[allow(non_snake_case)] #[wasm_bindgen(js_name = #marker_js_name, wasm_bindgen=::worker::wasm_bindgen)] - pub fn #marker_fn_name() -> ::worker::js_sys::JsString { - ::worker::js_sys::JsString::from(#target_name_str) - } + pub fn #marker_fn_name() {} #[wasm_bindgen(wasm_bindgen=::worker::wasm_bindgen)] #[::worker::consume] @@ -59,20 +58,20 @@ pub fn expand_macro(tokens: TokenStream) -> syn::Result { event: ::worker::wasm_bindgen::JsValue, step: ::worker::worker_sys::WorkflowStep ) -> ::worker::js_sys::Promise { - // SAFETY: The Cloudflare Workers runtime manages the Workflow instance - // lifecycle. The runtime guarantees that: - // 1. The instance is created before run() is called - // 2. The instance is not destroyed while any Promise returned by run() is pending - // 3. WASM execution is single-threaded, so no concurrent access is possible - // This is the same lifecycle model used by Durable Objects and WorkerEntrypoint. + // SAFETY: widen `&Self` to `&'static Self`. The Workers runtime keeps + // `self` alive until the returned Promise settles, which is the + // lifecycle contract for Workflow instances. let static_self: &'static Self = unsafe { &*(self as *const _) }; ::worker::wasm_bindgen_futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { - let event = ::worker::WorkflowEvent::from_js(event) - .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string()))?; + let event: ::worker::WorkflowEvent<::Input> = + ::worker::WorkflowEvent::from_js(event) + .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string()))?; let step = ::worker::WorkflowStep::from(step); - ::run(static_self, event, step).await - .map_err(::worker::wasm_bindgen::JsValue::from) + let output = ::run(static_self, event, step).await + .map_err(::worker::wasm_bindgen::JsValue::from)?; + ::worker::serialize_as_object(&output) + .map_err(|e| ::worker::wasm_bindgen::JsValue::from_str(&e.to_string())) })) } } diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 6527b2993..7afca6d0f 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -149,7 +149,9 @@ use std::result::Result as StdResult; #[doc(hidden)] pub use async_trait; pub use js_sys; +#[cfg(feature = "workflow")] pub use serde_json; +#[cfg(feature = "workflow")] pub use serde_wasm_bindgen; pub use url::Url; pub use wasm_bindgen; diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index b93e03522..5692c8b78 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -1,4 +1,12 @@ //! Cloudflare Workflows support for Rust Workers. +//! +//! ## `Send`/`Sync` for JS-backed types +//! +//! Several types in this module wrap JS objects and have `unsafe impl Send` / +//! `unsafe impl Sync`. WASM is single-threaded and these JS objects only ever +//! live on the main thread; the impls exist solely so the types can be held +//! across `await` points in async machinery that demands `Send`. Cross-thread +//! access is impossible in the Workers runtime. use std::future::Future; use std::panic::AssertUnwindSafe; @@ -18,10 +26,13 @@ use crate::env::EnvBinding; use crate::send::SendFuture; use crate::Result; -/// Serialize a value to a JS object, with maps serialized as plain objects. +/// Serialize a value to a JS object, with maps serialized as plain objects +/// rather than `Map` instances. /// -/// Use this for return values from [`WorkflowEntrypoint::run`] that must be -/// plain JS objects rather than `Map` instances. +/// This exists for the macro-generated entrypoint to convert +/// [`WorkflowEntrypoint::Output`] into the JS shape the Workflows runtime +/// expects. It's not part of the user-facing API. +#[doc(hidden)] pub fn serialize_as_object( value: &T, ) -> std::result::Result { @@ -54,10 +65,7 @@ pub struct Workflow { inner: WorkflowBindingSys, } -// SAFETY: WASM is single-threaded. These types wrap JS objects that are only -// accessed from the main thread. Send/Sync are implemented to satisfy Rust's -// async machinery (e.g., holding references across await points), but actual -// cross-thread access is impossible in the Workers runtime. +// SAFETY: see module-level docs. unsafe impl Send for Workflow {} unsafe impl Sync for Workflow {} @@ -68,40 +76,57 @@ impl Workflow { Ok(WorkflowInstance::from_js(result)) } - /// Create a new workflow instance. + /// Create a new workflow instance with the given options. pub async fn create( &self, - options: Option>, + options: CreateOptions, ) -> Result { - let js_options = match options { - Some(opts) => serde_wasm_bindgen::to_value(&opts)?, - None => JsValue::UNDEFINED, - }; + let js_options = serialize_create_options(&options)?; let result = SendFuture::new(self.inner.create(js_options)).await?; Ok(WorkflowInstance::from_js(result)) } + /// Create a new workflow instance with no params and a runtime-generated id. + pub async fn create_default(&self) -> Result { + self.create(CreateOptions::<()>::default()).await + } + /// Create a batch of workflow instances (limited to 100 at a time). pub async fn create_batch( &self, batch: Vec>, ) -> Result> { - let js_array = js_sys::Array::new(); - for opts in batch { - js_array.push(&serde_wasm_bindgen::to_value(&opts)?); - } + let js_array = batch + .iter() + .map(|opts| serialize_create_options(opts)) + .collect::>()?; let result = SendFuture::new(self.inner.create_batch(&js_array)).await?; let result_array: js_sys::Array = result.unchecked_into(); - - let len = result_array.length(); - let mut instances = Vec::with_capacity(len as usize); - for i in 0..len { - instances.push(WorkflowInstance::from_js(result_array.get(i))); - } - Ok(instances) + Ok(result_array.iter().map(WorkflowInstance::from_js).collect()) } } +// Serializer used everywhere we hand a payload off to the Workflows runtime. +// +// - `serialize_missing_as_null(true)`: the runtime destructures `{ params = {} }` +// from the create options, so a missing (or `undefined`) `params` becomes `{}`, +// which isn't nullish and can't be deserialized as `()` for `Input = ()` workflows. +// - `serialize_maps_as_objects(true)`: deserializing a JS `Map` back into a Rust +// struct fails (the struct deserializer reads named fields off an object; +// Maps don't expose entries as own properties). Emitting plain objects keeps +// the round-trip working for `serde_json::Value::Object`, `HashMap`, etc. +fn workflow_serializer() -> serde_wasm_bindgen::Serializer { + serde_wasm_bindgen::Serializer::new() + .serialize_missing_as_null(true) + .serialize_maps_as_objects(true) +} + +fn serialize_create_options(options: &CreateOptions) -> Result { + options + .serialize(&workflow_serializer()) + .map_err(Into::into) +} + impl EnvBinding for Workflow { const TYPE_NAME: &'static str = "Workflow"; @@ -156,7 +181,8 @@ impl AsRef for Workflow { pub struct CreateOptions { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] + // Always serialize `params` (as `null` when `None`) so the runtime sees an + // explicit value rather than falling through to its `params = {}` default. pub params: Option, #[serde(skip_serializing_if = "Option::is_none")] pub retention: Option, @@ -188,7 +214,7 @@ pub struct WorkflowInstance { inner: WorkflowInstanceSys, } -// SAFETY: See Workflow for rationale - WASM is single-threaded. +// SAFETY: see module-level docs. unsafe impl Send for WorkflowInstance {} unsafe impl Sync for WorkflowInstance {} @@ -243,7 +269,7 @@ impl WorkflowInstance { type_: &'a str, payload: P, } - let event = serde_wasm_bindgen::to_value(&SendEventPayload { type_, payload })?; + let event = SendEventPayload { type_, payload }.serialize(&workflow_serializer())?; SendFuture::new(self.inner.send_event(event)).await?; Ok(()) } @@ -365,7 +391,7 @@ impl ResolvedStepConfig { impl Default for ResolvedStepConfig { fn default() -> Self { Self { - timeout: WorkflowSleepDuration::text("10 minutes"), + timeout: WorkflowSleepDuration::new(10, WorkflowDuration::Minutes), retries: ResolvedRetryConfig::default(), } } @@ -405,7 +431,7 @@ impl Default for ResolvedRetryConfig { fn default() -> Self { Self { limit: 5, - delay: WorkflowSleepDuration::text("1 second"), + delay: WorkflowSleepDuration::new(1, WorkflowDuration::Seconds), backoff: Backoff::Exponential, } } @@ -415,7 +441,7 @@ impl Default for ResolvedRetryConfig { #[derive(Debug)] pub struct WorkflowStep(WorkflowStepSys); -// SAFETY: See Workflow for rationale - WASM is single-threaded. +// SAFETY: see module-level docs. unsafe impl Send for WorkflowStep {} unsafe impl Sync for WorkflowStep {} @@ -570,6 +596,9 @@ impl WorkflowStep { } } +// Macro-internal: the `#[workflow]` proc-macro feeds the JS-side `WorkflowStep` +// in via this conversion. Not part of the user-facing API. +#[doc(hidden)] impl From for WorkflowStep { fn from(inner: WorkflowStepSys) -> Self { Self(inner) @@ -631,17 +660,21 @@ impl WorkflowStepEvent { } /// The event passed to a workflow's run method. +/// +/// `T` matches [`WorkflowEntrypoint::Input`]; the macro deserializes the JS +/// payload into `T` before calling `run`. #[derive(Debug, Clone)] -pub struct WorkflowEvent { - pub payload: JsValue, +pub struct WorkflowEvent { + pub payload: T, pub timestamp: crate::Date, pub instance_id: String, } -impl WorkflowEvent { +impl WorkflowEvent { + #[doc(hidden)] pub fn from_js(value: JsValue) -> Result { Ok(Self { - payload: get_property(&value, "payload")?, + payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, timestamp: get_timestamp_property(&value, "timestamp")?, instance_id: get_string_property(&value, "instanceId")?, }) @@ -691,10 +724,6 @@ impl WorkflowSleepDuration { ))) } - fn text(s: &str) -> Self { - Self(WorkflowSleepDurationInner::Text(s.to_string())) - } - fn to_js_value(&self) -> JsValue { match &self.0 { WorkflowSleepDurationInner::Text(s) => JsValue::from_str(s), @@ -784,9 +813,26 @@ impl From for crate::Error { pub trait HasWorkflowAttribute {} /// Trait for implementing a Workflow entrypoint. +/// +/// Pair this with `#[workflow]` on the struct definition. The macro generates +/// the wasm-bindgen glue and handles deserializing the incoming payload into +/// `Input` and serializing the returned `Output` back to JS. +/// +/// Use `Input = ()` for workflows with no params, and `Output = ()` if you +/// don't need to return anything meaningful. #[allow(async_fn_in_trait)] pub trait WorkflowEntrypoint: HasWorkflowAttribute { + /// Type of the `params` payload supplied to [`Workflow::create`]. + type Input: DeserializeOwned; + + /// Type returned to the runtime as the workflow's output. + type Output: Serialize; + fn new(ctx: crate::Context, env: crate::Env) -> Self; - async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result; + async fn run( + &self, + event: WorkflowEvent, + step: WorkflowStep, + ) -> Result; } From c68b874a256e9c194a7a3952a2b7552ea9ff365e Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 26 Apr 2026 22:46:18 -0500 Subject: [PATCH 19/21] cleanup workflow macro rustdoc --- worker-macros/src/lib.rs | 63 +++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/worker-macros/src/lib.rs b/worker-macros/src/lib.rs index ed3bb9ff9..b3df9174d 100644 --- a/worker-macros/src/lib.rs +++ b/worker-macros/src/lib.rs @@ -145,30 +145,67 @@ pub fn consume(_: TokenStream, _: TokenStream) -> TokenStream { } /// Integrate the struct with the Workers Runtime as a Workflow Entrypoint. -/// Requires the `WorkflowEntrypoint` trait with the workflow attribute macro on the struct. +/// Pair this attribute with an `impl WorkflowEntrypoint for ...` block that +/// declares the workflow's typed `Input`, `Output`, and `run` method. +/// +/// `Input` and `Output` are user-defined types implementing +/// `serde::Deserialize` and `serde::Serialize` respectively. The macro +/// deserializes the incoming payload into `Input` before calling `run`, and +/// serializes the returned `Output` for the runtime, so user code works +/// with plain Rust structs and never touches `JsValue`. +/// +/// Use `Input = ()` for workflows started without `params`, and +/// `Output = ()` when there's no return value. /// /// ## Example /// /// ```rust,ignore +/// use serde::{Deserialize, Serialize}; +/// use worker::*; +/// +/// #[derive(Deserialize)] +/// pub struct Params { pub email: String } +/// +/// #[derive(Serialize)] +/// pub struct Output { pub message: String } +/// /// #[workflow] -/// pub struct MyWorkflow { -/// env: Env, -/// } +/// pub struct MyWorkflow { env: Env } /// /// impl WorkflowEntrypoint for MyWorkflow { -/// fn new(ctx: Context, env: Env) -> Self { -/// Self { env } -/// } -/// -/// async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { -/// let result = step.do_("my step", || async { -/// Ok(serde_json::json!({"data": "value"})) -/// }).await?; +/// type Input = Params; +/// type Output = Output; +/// +/// fn new(_ctx: Context, env: Env) -> Self { Self { env } } +/// +/// async fn run( +/// &self, +/// event: WorkflowEvent, +/// step: WorkflowStep, +/// ) -> Result { +/// let email = event.payload.email.clone(); +/// let validated: bool = step +/// .do_("validate", move |_ctx| { +/// let email = email.clone(); +/// async move { Ok(email.contains('@')) } +/// }) +/// .await?; +/// +/// if !validated { +/// return Err(NonRetryableError::new("invalid email").into()); +/// } /// -/// Ok(result) +/// Ok(Output { +/// message: format!("processed {}", event.payload.email), +/// }) /// } /// } /// ``` +/// +/// If you need an untyped escape hatch (for dynamic payloads, or when +/// migrating from a JS workflow), use `serde_json::Value` as the `Input` +/// or `Output`. Typed structs are usually better: you get compile-time +/// field checks instead of runtime serialization errors. #[cfg(feature = "workflow")] #[proc_macro_attribute] pub fn workflow(_attr: TokenStream, item: TokenStream) -> TokenStream { From 7a88382fa6cef2eb83fd240e7f1b11799d8c6eb5 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 31 May 2026 13:21:25 -0500 Subject: [PATCH 20/21] fmt --- test/src/router.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/src/router.rs b/test/src/router.rs index 09add4989..a173de205 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -2,8 +2,8 @@ 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, workflow, ws, SomeSharedData, GLOBAL_SECOND_START, - GLOBAL_STATE, + service, socket, sql_counter, sql_iterator, user, workflow, ws, SomeSharedData, + GLOBAL_SECOND_START, GLOBAL_STATE, }; #[cfg(feature = "http")] use std::convert::TryInto; From 9a09263add42ae2edcd7f8414e9bd2f2eeac6638 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 28 Jun 2026 09:58:09 -0500 Subject: [PATCH 21/21] Add rollback, schedule, and restartopts. New features added to workflows since this PR started. https://developers.cloudflare.com/changelog/post/2026-06-02-cron-workflows/ https://developers.cloudflare.com/changelog/post/2026-06-05-saga-rollbacks/ https://developers.cloudflare.com/changelog/post/2026-06-16-rollback-options/ --- examples/workflow/README.md | 69 +++++++ examples/workflow/src/lib.rs | 45 +++- examples/workflow/wrangler.toml | 3 + package-lock.json | 76 +++---- package.json | 2 +- test/src/router.rs | 4 + test/src/workflow.rs | 102 +++++++++ test/tests/mf.ts | 10 +- test/tests/workflow.spec.ts | 50 +++++ test/wrangler.toml | 6 + worker-sys/src/types/workflow.rs | 20 ++ worker/src/workflow.rs | 345 ++++++++++++++++++++++++++++++- 12 files changed, 688 insertions(+), 44 deletions(-) create mode 100644 examples/workflow/README.md diff --git a/examples/workflow/README.md b/examples/workflow/README.md new file mode 100644 index 000000000..041a1b95c --- /dev/null +++ b/examples/workflow/README.md @@ -0,0 +1,69 @@ +# Workflows on Cloudflare Workers + +Durable, multi-step [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) written in Rust with the `#[workflow]` macro and `worker::WorkflowEntrypoint`. + +`MyWorkflow` (in `src/lib.rs`) runs a small pipeline and exercises most of the Workflows surface: + +- **Durable steps.** `step.do_(...)` and `step.do_with_config(...)` persist their return values and replay them instead of re-running. +- **Retries & backoff.** `StepConfig` / `RetryConfig` set a per-step `limit`, `delay`, and `Backoff` strategy. The `send-notification` step fails randomly to show retries in action. +- **Non-retryable errors.** `NonRetryableError` fails the instance immediately (used when the email is invalid). +- **Sleeping.** `step.sleep(...)` hibernates the instance (10 seconds here) without burning CPU. +- **Saga rollback.** `step.do_with_rollback(...)` attaches a compensation handler that the runtime invokes if a later step fails. See `reserve-inventory`. +- **Cron triggers.** When started from a `schedules` cron (see `wrangler.toml`), `event.schedule` and `event.workflow_name` are populated. + +A `fetch` handler is included so you can drive the workflow over HTTP. + +## Routes + +| Route | Description | +|---|---| +| `POST /workflow` | Create an instance. Body: `{ "email": "...", "name": "..." }`. Returns the instance `id`. | +| `GET /workflow/{id}` | Get an instance's status, output, and error. | +| `POST /workflow/{id}/pause` | Pause a running instance. | +| `POST /workflow/{id}/resume` | Resume a paused instance. | + +## Running locally + +```sh +npx wrangler dev +``` + +`wrangler dev` runs the `[build]` command from `wrangler.toml`, which invokes `worker-build`. If you don't have it, install it first with `cargo install worker-build` (and point the build command at it, or drop the `../../target/release/` prefix). + +> Saga rollbacks are a recent Workflows feature. Local development needs a current `wrangler`/`workerd`; an older toolchain will run the steps but skip the rollback. + +### Walkthrough + +1. Create an instance: + + ```sh + curl -X POST http://localhost:8787/workflow \ + -H 'Content-Type: application/json' \ + -d '{"email":"user@example.com","name":"Ada"}' + # → {"id":"","message":"Workflow created"} + ``` + +2. Poll its status (it sleeps for 10s mid-run, so give it a moment): + + ```sh + curl http://localhost:8787/workflow/ + # → {"id":"...","status":"Running","error":null,"output":null} + # ...later... + # → {"id":"...","status":"Complete","output":{"message":"Workflow completed for Ada","steps_completed":4}} + ``` + + Passing an email without an `@` makes the `validate-params` step throw a + `NonRetryableError`, so the instance ends up `Errored` instead. + +3. Pause / resume while it's running (e.g. during the 10s sleep): + + ```sh + curl -X POST http://localhost:8787/workflow//pause + curl -X POST http://localhost:8787/workflow//resume + ``` + +## Deploying + +```sh +npx wrangler deploy +``` diff --git a/examples/workflow/src/lib.rs b/examples/workflow/src/lib.rs index ee4393fe5..c51fc013e 100644 --- a/examples/workflow/src/lib.rs +++ b/examples/workflow/src/lib.rs @@ -28,7 +28,17 @@ impl WorkflowEntrypoint for MyWorkflow { } async fn run(&self, event: WorkflowEvent, step: WorkflowStep) -> Result { - console_log!("Workflow started with instance ID: {}", event.instance_id); + console_log!( + "Workflow '{}' started with instance ID: {}", + event.workflow_name, + event.instance_id + ); + + // When triggered by a cron schedule (configured via `schedules` on the + // workflow binding in wrangler.toml), `event.schedule` is populated. + if let Some(schedule) = &event.schedule { + console_log!("Triggered by cron schedule: {}", schedule.cron); + } let params = event.payload; @@ -42,6 +52,7 @@ impl WorkflowEntrypoint for MyWorkflow { backoff: None, }), timeout: None, + sensitive: None, }, move |ctx| { let email = email_for_validation.clone(); @@ -91,6 +102,7 @@ impl WorkflowEntrypoint for MyWorkflow { backoff: Some(Backoff::Exponential), }), timeout: Some("1 minute".into()), + sensitive: None, }, move |_ctx| { let email = email_for_step3.clone(); @@ -110,9 +122,38 @@ impl WorkflowEntrypoint for MyWorkflow { console_log!("Step 3 completed: {:?}", notification_result); + // Step 4: saga-style rollback. If a later step fails, the runtime + // invokes the rollback handler (in reverse order of completion) to undo + // this step's side effects. The handler receives the step's output, the + // original step context, and the error that triggered the rollback. + let reservation = step + .do_with_rollback( + "reserve-inventory", + |_ctx| async move { + console_log!("Reserving inventory"); + Ok(serde_json::json!({ "reservation_id": "resv_123" })) + }, + Rollback::new( + |rollback: WorkflowRollbackContext| async move { + if let Some(output) = &rollback.output { + console_log!( + "Rolling back '{}' (caused by: {}) — releasing {}", + rollback.ctx.step.name, + rollback.error.message, + output + ); + } + Ok(()) + }, + ), + ) + .await?; + + console_log!("Step 4 completed: {:?}", reservation); + Ok(MyOutput { message: format!("Workflow completed for {}", params.name), - steps_completed: 3, + steps_completed: 4, }) } } diff --git a/examples/workflow/wrangler.toml b/examples/workflow/wrangler.toml index 5c0671168..a454c5887 100644 --- a/examples/workflow/wrangler.toml +++ b/examples/workflow/wrangler.toml @@ -11,3 +11,6 @@ command = "RUSTFLAGS='--cfg=web_sys_unstable_apis' ../../target/release/worker-b name = "my-workflow" binding = "MY_WORKFLOW" class_name = "MyWorkflow" +# Optionally trigger this workflow on a cron schedule. When triggered this way, +# `WorkflowEvent::schedule` is populated with the matching cron expression. +# schedules = ["0 * * * *", "*/15 * * * *"] diff --git a/package-lock.json b/package-lock.json index ce9ddb13e..344dab8cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20260424.0", + "miniflare": "4.20260625.0", "typescript": "^5.8.3", "uuid": "^14.0.0", "vitest": "^3.2.6" @@ -350,9 +350,9 @@ "license": "MIT" }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260424.1.tgz", - "integrity": "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", + "integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==", "cpu": [ "x64" ], @@ -367,9 +367,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260424.1.tgz", - "integrity": "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz", + "integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==", "cpu": [ "arm64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260424.1.tgz", - "integrity": "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz", + "integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==", "cpu": [ "x64" ], @@ -401,9 +401,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260424.1.tgz", - "integrity": "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz", + "integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260424.1.tgz", - "integrity": "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz", + "integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==", "cpu": [ "x64" ], @@ -2737,24 +2737,24 @@ } }, "node_modules/miniflare": { - "version": "4.20260424.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260424.0.tgz", - "integrity": "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw==", + "version": "4.20260625.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", + "integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260424.1", - "ws": "8.18.0", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260625.1", + "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/mri": { @@ -3481,9 +3481,9 @@ } }, "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -3784,9 +3784,9 @@ } }, "node_modules/workerd": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260424.1.tgz", - "integrity": "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ==", + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz", + "integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3797,17 +3797,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260424.1", - "@cloudflare/workerd-darwin-arm64": "1.20260424.1", - "@cloudflare/workerd-linux-64": "1.20260424.1", - "@cloudflare/workerd-linux-arm64": "1.20260424.1", - "@cloudflare/workerd-windows-64": "1.20260424.1" + "@cloudflare/workerd-darwin-64": "1.20260625.1", + "@cloudflare/workerd-darwin-arm64": "1.20260625.1", + "@cloudflare/workerd-linux-64": "1.20260625.1", + "@cloudflare/workerd-linux-arm64": "1.20260625.1", + "@cloudflare/workerd-windows-64": "1.20260625.1" } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 28ca3d4e1..ddab0a3f6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "homepage": "https://github.com/cloudflare/workers-rs#readme", "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20260424.0", + "miniflare": "^4.20260625.0", "typescript": "^5.8.3", "uuid": "^14.0.0", "vitest": "^3.2.6" diff --git a/test/src/router.rs b/test/src/router.rs index a173de205..89eade51e 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -255,6 +255,10 @@ macro_rules! add_routes ( add_route!($obj, post, format_route!("/workflow/lifecycle/resume/{}", "id"), workflow::handle_lifecycle_workflow_resume); add_route!($obj, post, format_route!("/workflow/lifecycle/terminate/{}", "id"), workflow::handle_lifecycle_workflow_terminate); add_route!($obj, post, format_route!("/workflow/lifecycle/restart/{}", "id"), workflow::handle_lifecycle_workflow_restart); + add_route!($obj, post, format_route!("/workflow/lifecycle/restart-from/{}", "id"), workflow::handle_lifecycle_workflow_restart_from); + add_route!($obj, post, "/workflow/rollback/create", workflow::handle_rollback_workflow_create); + add_route!($obj, get, format_route!("/workflow/rollback/status/{}", "id"), workflow::handle_rollback_workflow_status); + add_route!($obj, get, format_route!("/workflow/rollback/marker/{}", "id"), workflow::handle_rollback_workflow_marker); 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/src/workflow.rs b/test/src/workflow.rs index 828e4842b..0f1649d80 100644 --- a/test/src/workflow.rs +++ b/test/src/workflow.rs @@ -81,6 +81,7 @@ impl WorkflowEntrypoint for TestWorkflow { backoff: None, }), timeout: None, + sensitive: None, }, move |ctx| { let value = value_for_validation.clone(); @@ -314,3 +315,104 @@ pub async fn handle_lifecycle_workflow_restart( instance.restart().await?; Response::ok("restarted") } + +pub async fn handle_lifecycle_workflow_restart_from( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "LIFECYCLE_WORKFLOW").await?; + instance + .restart_with_options(RestartOptions { + from: Some(RestartFrom { + name: "long-sleep".to_string(), + count: Some(1), + type_: Some(StepType::Sleep), + }), + }) + .await?; + Response::ok("restarted") +} + +// Saga rollback: a first step succeeds with a rollback handler attached, then a +// second step fails non-retryably. The runtime then invokes the rollback, which +// records a marker in KV keyed by the instance id so the test can confirm it ran. +fn rollback_marker_key(instance_id: &str) -> String { + format!("rollback:{instance_id}") +} + +#[workflow] +pub struct RollbackWorkflow { + env: Env, +} + +impl WorkflowEntrypoint for RollbackWorkflow { + type Input = (); + type Output = (); + + fn new(_ctx: Context, env: Env) -> Self { + Self { env } + } + + async fn run(&self, event: WorkflowEvent<()>, step: WorkflowStep) -> Result<()> { + let kv = self.env.kv("ROLLBACK_KV")?; + let instance_id = event.instance_id.clone(); + + step.do_with_rollback( + "reserve", + |_ctx| async move { Ok(serde_json::json!({ "reserved": true })) }, + Rollback::new( + move |rollback: WorkflowRollbackContext| { + let kv = kv.clone(); + let key = rollback_marker_key(&instance_id); + let reason = rollback.error.message.clone(); + async move { + kv.put(&key, reason)?.execute().await?; + Ok(()) + } + }, + ), + ) + .await?; + + // Downstream failure triggers the rollback registered above. + step.do_("explode", |_ctx| async move { + Err::(NonRetryableError::new("kaboom").into()) + }) + .await?; + + Ok(()) + } +} + +pub async fn handle_rollback_workflow_create( + _req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + create_workflow_no_params(&env, "ROLLBACK_WORKFLOW").await +} + +pub async fn handle_rollback_workflow_status( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let instance = get_workflow_instance(&req, &env, "ROLLBACK_WORKFLOW").await?; + let status = instance.status().await?; + status_response(status) +} + +pub async fn handle_rollback_workflow_marker( + req: Request, + env: Env, + _data: crate::SomeSharedData, +) -> Result { + let id = last_path_segment(&req)?; + let marker = env + .kv("ROLLBACK_KV")? + .get(&rollback_marker_key(&id)) + .text() + .await?; + Response::from_json(&serde_json::json!({ "marker": marker })) +} diff --git a/test/tests/mf.ts b/test/tests/mf.ts index a355a0309..f5bbf743c 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -77,7 +77,7 @@ const mf_instance = new Miniflare({ useSQLite: true, }, }, - kvNamespaces: ["SOME_NAMESPACE", "FILE_SIZES", "TEST"], + kvNamespaces: ["SOME_NAMESPACE", "FILE_SIZES", "TEST", "ROLLBACK_KV"], serviceBindings: { async remote() { return new Response("hello world"); @@ -131,6 +131,11 @@ const mf_instance = new Miniflare({ className: "LifecycleWorkflow", scriptName: "workflow-worker", }, + ROLLBACK_WORKFLOW: { + name: "rollback-workflow", + className: "RollbackWorkflow", + scriptName: "workflow-worker", + }, }, ratelimits: { TEST_RATE_LIMITER: { @@ -160,6 +165,9 @@ const mf_instance = new Miniflare({ { type: "CompiledWasm", include: ["**/*.wasm"], fallthrough: true }, ], compatibilityDate: "2025-07-24", + // Shared with the main worker (same namespace id) so the RollbackWorkflow's + // rollback handler can record a marker the test reads back. + kvNamespaces: ["ROLLBACK_KV"], }, { name: "mini-analytics-engine", diff --git a/test/tests/workflow.spec.ts b/test/tests/workflow.spec.ts index d937a4c7d..9320fa4c8 100644 --- a/test/tests/workflow.spec.ts +++ b/test/tests/workflow.spec.ts @@ -145,6 +145,16 @@ describe("workflow", () => { await lifecycleAction("terminate", id); }); + test("restart a running workflow from a specific step", async () => { + const id = await createLifecycleWorkflow(); + + expect((await lifecycleAction("restart-from", id)).status).toBe(200); + const status = await pollUntil(id, (s) => s !== "Queued"); + expect(["Running", "Waiting", "Queued"]).toContain(status); + + await lifecycleAction("terminate", id); + }); + test("wait_for_event receives sent event", async () => { const createResp = await mf.dispatchFetch( `${mfUrl}workflow/event/create`, @@ -195,4 +205,44 @@ describe("workflow", () => { type: "approval", }); }); + + test("saga rollback runs when a downstream step fails", async () => { + const createResp = await mf.dispatchFetch( + `${mfUrl}workflow/rollback/create`, + { method: "POST" } + ); + expect(createResp.status).toBe(200); + const { id } = (await createResp.json()) as { id: string }; + expect(id).toBeDefined(); + + // The second step fails non-retryably, so the instance ends up errored. + let status: string | undefined; + for (let i = 0; i < 30; i++) { + const statusResp = await mf.dispatchFetch( + `${mfUrl}workflow/rollback/status/${id}` + ); + expect(statusResp.status).toBe(200); + status = ((await statusResp.json()) as { status: string }).status; + if (status === "Complete" || status === "Errored") break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + expect(status).toBe("Errored"); + + // The rollback handler records a marker in KV; poll until it appears. + let marker: string | null = null; + for (let i = 0; i < 30; i++) { + const markerResp = await mf.dispatchFetch( + `${mfUrl}workflow/rollback/marker/${id}` + ); + expect(markerResp.status).toBe(200); + marker = ((await markerResp.json()) as { marker: string | null }).marker; + if (marker !== null) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + // The marker is the error message that triggered the rollback. Its presence + // confirms the handler ran and saw the downstream failure. + expect(marker).not.toBeNull(); + expect(marker).toContain("kaboom"); + }); }); diff --git a/test/wrangler.toml b/test/wrangler.toml index 406aaf4c7..a73205291 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -7,6 +7,7 @@ kv_namespaces = [ { binding = "SOME_NAMESPACE", id = "SOME_NAMESPACE", preview_id = "SOME_NAMESPACE" }, { binding = "FILE_SIZES", id = "FILE_SIZES", preview_id = "FILE_SIZES" }, { binding = "TEST", id = "TEST", preview_id = "TEST" }, + { binding = "ROLLBACK_KV", id = "ROLLBACK_KV", preview_id = "ROLLBACK_KV" }, ] [vars] @@ -101,6 +102,11 @@ name = "lifecycle-workflow" binding = "LIFECYCLE_WORKFLOW" class_name = "LifecycleWorkflow" +[[workflows]] +name = "rollback-workflow" +binding = "ROLLBACK_WORKFLOW" +class_name = "RollbackWorkflow" + [[ratelimits]] name = "TEST_RATE_LIMITER" namespace_id = "1" diff --git a/worker-sys/src/types/workflow.rs b/worker-sys/src/types/workflow.rs index ee3ce2d3e..fe24181f0 100644 --- a/worker-sys/src/types/workflow.rs +++ b/worker-sys/src/types/workflow.rs @@ -21,6 +21,20 @@ extern "C" { callback: &js_sys::Function, ) -> Result; + // Saga-style rollback variant. We always route through this 4-argument + // overload (`do(name, config, callback, rollbackOptions)`) rather than the + // 3-argument `do(name, callback, rollbackOptions)` form: when both `config` + // and `rollbackOptions` are plain objects the 3-argument form is positionally + // ambiguous, and some runtimes (e.g. older Miniflare) misparse it. + #[wasm_bindgen(method, catch, js_name = "do")] + pub async fn do_with_rollback( + this: &WorkflowStep, + name: &str, + config: JsValue, + callback: &js_sys::Function, + rollback_options: JsValue, + ) -> Result; + #[wasm_bindgen(method, catch)] pub async fn sleep( this: &WorkflowStep, @@ -76,6 +90,12 @@ extern "C" { #[wasm_bindgen(method, catch)] pub async fn restart(this: &WorkflowInstanceSys) -> Result; + #[wasm_bindgen(method, catch, js_name = "restart")] + pub async fn restart_with_options( + this: &WorkflowInstanceSys, + options: JsValue, + ) -> Result; + #[wasm_bindgen(method, catch)] pub async fn status(this: &WorkflowInstanceSys) -> Result; diff --git a/worker/src/workflow.rs b/worker/src/workflow.rs index 5692c8b78..0d6d22042 100644 --- a/worker/src/workflow.rs +++ b/worker/src/workflow.rs @@ -249,12 +249,22 @@ impl WorkflowInstance { Ok(()) } - /// Restart the workflow instance. + /// Restart the workflow instance from the beginning. pub async fn restart(&self) -> Result<()> { SendFuture::new(self.inner.restart()).await?; Ok(()) } + /// Restart the workflow instance, optionally from a specific step. + /// + /// When [`RestartOptions::from`] is set, cached results for all steps before + /// the named step are preserved and execution resumes from that step. + pub async fn restart_with_options(&self, options: RestartOptions) -> Result<()> { + let options_js = options.serialize(&workflow_serializer())?; + SendFuture::new(self.inner.restart_with_options(options_js)).await?; + Ok(()) + } + /// Get the current status of the workflow instance. pub async fn status(&self) -> Result { let result = SendFuture::new(self.inner.status()).await?; @@ -307,6 +317,40 @@ pub struct InstanceError { pub message: String, } +/// Options for [`WorkflowInstance::restart_with_options`]. +#[derive(Debug, Clone, Default, Serialize)] +pub struct RestartOptions { + /// Restart from a specific step. If `None`, the instance restarts from the + /// beginning. The step must exist in the instance's execution history. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, +} + +/// Identifies the step to restart a workflow instance from. +#[derive(Debug, Clone, Serialize)] +pub struct RestartFrom { + /// The step name as defined in your workflow code. + pub name: String, + /// 1-indexed occurrence of this step name. Use when the same step name + /// appears multiple times (e.g. in a loop). Defaults to `1`. + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, + /// Step type filter. Use when different step types share the same name. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, +} + +/// The kind of a workflow step, used to disambiguate steps in [`RestartFrom`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StepType { + #[serde(rename = "do")] + Do, + #[serde(rename = "sleep")] + Sleep, + #[serde(rename = "waitForEvent")] + WaitForEvent, +} + /// Context passed to step callbacks with information about the current step invocation. #[derive(Debug, Clone)] pub struct WorkflowStepContext { @@ -366,11 +410,71 @@ impl WorkflowStepContext { } } +/// Context passed to a step's rollback (compensation) handler. +/// +/// `T` is the step's output type. See [`WorkflowStep::do_with_rollback`]. +#[derive(Debug, Clone)] +pub struct WorkflowRollbackContext { + /// The original context of the step being rolled back. + pub ctx: WorkflowStepContext, + /// The error that triggered the rollback. + pub error: StepError, + /// The output the step produced, if it completed successfully before the + /// downstream failure. `None` if the step itself never produced output. + pub output: Option, +} + +impl WorkflowRollbackContext { + fn from_js(value: &JsValue) -> std::result::Result { + let ctx = WorkflowStepContext::from_js(&get_property(value, "ctx")?); + let error = StepError::from_js(&get_property(value, "error")?); + let output_val = get_property(value, "output")?; + let output = if output_val.is_undefined() || output_val.is_null() { + None + } else { + Some(serde_wasm_bindgen::from_value(output_val).map_err(JsValue::from)?) + }; + Ok(Self { ctx, error, output }) + } +} + +/// The error that triggered a step rollback, mirroring a JS `Error`. +#[derive(Debug, Clone)] +pub struct StepError { + pub name: String, + pub message: String, +} + +impl StepError { + fn from_js(value: &JsValue) -> Self { + let read = |key: &str| { + get_property(value, key) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default() + }; + Self { + name: read("name"), + message: read("message"), + } + } +} + +impl std::fmt::Display for StepError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.name, self.message) + } +} + +impl std::error::Error for StepError {} + /// A step configuration with runtime defaults applied. #[derive(Debug, Clone)] pub struct ResolvedStepConfig { pub timeout: WorkflowSleepDuration, pub retries: ResolvedRetryConfig, + /// Set when the step was marked sensitive via [`StepConfig::sensitive`]. + pub sensitive: Option, } impl ResolvedStepConfig { @@ -384,7 +488,14 @@ impl ResolvedStepConfig { .ok() .map(|v| ResolvedRetryConfig::from_js(&v)) .unwrap_or(defaults.retries); - Self { timeout, retries } + let sensitive = get_property(val, "sensitive") + .ok() + .and_then(|v| serde_wasm_bindgen::from_value(v).ok()); + Self { + timeout, + retries, + sensitive, + } } } @@ -393,6 +504,7 @@ impl Default for ResolvedStepConfig { Self { timeout: WorkflowSleepDuration::new(10, WorkflowDuration::Minutes), retries: ResolvedRetryConfig::default(), + sensitive: None, } } } @@ -493,6 +605,33 @@ impl WorkflowStep { }) } + /// Wrap a rollback handler into a JS function and hand it to the JS GC. + /// + /// Unlike step callbacks (invoked synchronously during the `do_*` await), + /// a rollback handler runs later, when a downstream step fails, + /// after `run` has already returned. The closure must therefore outlive + /// this call, so we leak it via `into_js_value` rather than dropping it at + /// the end of the step. + fn wrap_rollback_callback(rollback: RF) -> JsValue + where + T: DeserializeOwned + 'static, + RF: Fn(WorkflowRollbackContext) -> RFut + 'static, + RFut: Future> + 'static, + { + let rollback = Rc::new(AssertUnwindSafe(rollback)); + let closure = wasm_bindgen::closure::Closure:: js_sys::Promise>::new( + move |ctx: JsValue| -> js_sys::Promise { + let rollback = rollback.clone(); + future_to_promise(AssertUnwindSafe(async move { + let context = WorkflowRollbackContext::::from_js(&ctx)?; + (rollback.0)(context).await.map_err(JsValue::from)?; + Ok(JsValue::UNDEFINED) + })) + }, + ); + closure.into_js_value() + } + /// Execute a named step. The callback's return value is persisted and /// returned without re-executing on replay. pub async fn do_(&self, name: &str, callback: F) -> Result @@ -526,6 +665,102 @@ impl WorkflowStep { Ok(serde_wasm_bindgen::from_value(result)?) } + /// Execute a named step with a saga-style rollback (compensation) handler. + /// + /// If a later step in the workflow fails, the runtime invokes the + /// `rollback` handler to undo this step's side effects. Handlers run in + /// reverse order of step completion. The handler receives a + /// [`WorkflowRollbackContext`] with the original step context, the error + /// that triggered the rollback, and this step's output. + /// + /// Use [`Rollback::new`] for a handler with default retry/timeout, or + /// [`Rollback::with_config`] to customize them. + pub async fn do_with_rollback( + &self, + name: &str, + callback: F, + rollback: Rollback, + ) -> Result + where + T: Serialize + DeserializeOwned + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + RF: Fn(WorkflowRollbackContext) -> RFut + 'static, + RFut: Future> + 'static, + { + self.do_rollback_inner(name, StepConfig::default(), callback, rollback) + .await + } + + /// Execute a named step with retry/timeout configuration and a saga-style + /// rollback (compensation) handler. See [`do_with_rollback`] for rollback + /// semantics. + /// + /// [`do_with_rollback`]: WorkflowStep::do_with_rollback + pub async fn do_with_config_and_rollback( + &self, + name: &str, + config: StepConfig, + callback: F, + rollback: Rollback, + ) -> Result + where + T: Serialize + DeserializeOwned + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + RF: Fn(WorkflowRollbackContext) -> RFut + 'static, + RFut: Future> + 'static, + { + self.do_rollback_inner(name, config, callback, rollback) + .await + } + + async fn do_rollback_inner( + &self, + name: &str, + config: StepConfig, + callback: F, + rollback: Rollback, + ) -> Result + where + T: Serialize + DeserializeOwned + 'static, + F: Fn(WorkflowStepContext) -> Fut + 'static, + Fut: Future> + 'static, + RF: Fn(WorkflowRollbackContext) -> RFut + 'static, + RFut: Future> + 'static, + { + let config_js = serde_wasm_bindgen::to_value(&config)?; + let closure = Self::wrap_callback(callback); + let js_fn = closure.as_ref().unchecked_ref::(); + + let rollback_options = Object::new(); + let rollback_fn = Self::wrap_rollback_callback(rollback.handler); + Reflect::set( + &rollback_options, + &JsValue::from_str("rollback"), + &rollback_fn, + ) + .map_err(|e| crate::Error::JsError(format!("failed to set rollback: {e:?}")))?; + if let Some(rollback_config) = rollback.config { + let cfg_js = serde_wasm_bindgen::to_value(&rollback_config)?; + Reflect::set( + &rollback_options, + &JsValue::from_str("rollbackConfig"), + &cfg_js, + ) + .map_err(|e| crate::Error::JsError(format!("failed to set rollbackConfig: {e:?}")))?; + } + + let result = SendFuture::new(self.0.do_with_rollback( + name, + config_js, + js_fn, + rollback_options.into(), + )) + .await?; + Ok(serde_wasm_bindgen::from_value(result)?) + } + /// Execute a named step whose return value is a `ReadableStream`. /// /// Unlike serialized step outputs, stream return values aren't subject to @@ -612,6 +847,18 @@ pub struct StepConfig { pub retries: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, + /// Marks the step's output as sensitive so it is redacted from observability. + #[serde(skip_serializing_if = "Option::is_none")] + pub sensitive: Option, +} + +/// Marks data attached to a step as sensitive so it is redacted from logs and +/// the observability UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StepSensitivity { + /// Redact the step's output. + Output, } /// Retry configuration for a workflow step. @@ -632,6 +879,55 @@ pub enum Backoff { Exponential, } +/// Retry and timeout configuration for a step's rollback handler. +/// +/// This is the rollback counterpart of [`StepConfig`]; only `retries` and +/// `timeout` apply to rollback handlers. +#[derive(Debug, Clone, Default, Serialize)] +pub struct RollbackConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub retries: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// A rollback (compensation) handler plus its optional configuration, passed to +/// [`WorkflowStep::do_with_rollback`] / [`WorkflowStep::do_with_config_and_rollback`]. +/// +/// `RF` is an `async` closure of the form +/// `Fn(WorkflowRollbackContext) -> impl Future>`, where +/// `T` is the step's output type. +pub struct Rollback { + handler: RF, + config: Option, +} + +impl std::fmt::Debug for Rollback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Rollback") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl Rollback { + /// Create a rollback handler with default retry/timeout configuration. + pub fn new(handler: RF) -> Self { + Self { + handler, + config: None, + } + } + + /// Create a rollback handler with custom retry/timeout configuration. + pub fn with_config(handler: RF, config: RollbackConfig) -> Self { + Self { + handler, + config: Some(config), + } + } +} + /// Options for waiting for an external event. #[derive(Debug, Clone, Serialize)] pub struct WaitForEventOptions { @@ -647,6 +943,8 @@ pub struct WorkflowStepEvent { pub payload: T, pub timestamp: crate::Date, pub type_: String, + /// Set when the event payload was marked sensitive. + pub sensitive: Option, } impl WorkflowStepEvent { @@ -655,6 +953,9 @@ impl WorkflowStepEvent { payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, timestamp: get_timestamp_property(&value, "timestamp")?, type_: get_string_property(&value, "type")?, + sensitive: get_property(&value, "sensitive") + .ok() + .and_then(|v| serde_wasm_bindgen::from_value(v).ok()), }) } } @@ -668,6 +969,11 @@ pub struct WorkflowEvent { pub payload: T, pub timestamp: crate::Date, pub instance_id: String, + /// The name of the workflow this instance belongs to. + pub workflow_name: String, + /// Present when the instance was triggered by a cron schedule configured + /// via the `schedules` field of the workflow binding in `wrangler.toml`. + pub schedule: Option, } impl WorkflowEvent { @@ -677,10 +983,45 @@ impl WorkflowEvent { payload: serde_wasm_bindgen::from_value(get_property(&value, "payload")?)?, timestamp: get_timestamp_property(&value, "timestamp")?, instance_id: get_string_property(&value, "instanceId")?, + // Older runtimes don't populate `workflowName`; default rather than fail. + workflow_name: get_property(&value, "workflowName") + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(), + schedule: WorkflowCronSchedule::from_js(&value)?, }) } } +/// Details of the cron trigger that started a scheduled workflow instance. +/// +/// Populated on [`WorkflowEvent::schedule`] when the instance was started by a +/// cron schedule declared on the workflow binding (the `schedules` field in +/// `wrangler.toml`). +#[derive(Debug, Clone)] +pub struct WorkflowCronSchedule { + /// The cron expression that triggered this instance. + pub cron: String, + /// The scheduled trigger time, if the runtime reported one. + pub scheduled_time: Option, +} + +impl WorkflowCronSchedule { + /// Parse the optional `schedule` property off a workflow event object. + fn from_js(event: &JsValue) -> Result> { + let schedule = get_property(event, "schedule")?; + if schedule.is_undefined() || schedule.is_null() { + return Ok(None); + } + let scheduled_time = get_f64_property(&schedule, "scheduledTime") + .map(|ms| crate::Date::new(crate::DateInit::Millis(ms as u64))); + Ok(Some(Self { + cron: get_string_property(&schedule, "cron")?, + scheduled_time, + })) + } +} + /// Unit of time for workflow durations. #[derive(Debug, Clone, Copy)] pub enum WorkflowDuration {