feat(wfe-deno): Deno bindings for the WFE workflow engine

This commit is contained in:
2026-04-05 22:06:07 +01:00
parent afb91c66bd
commit 9a08882e28
14 changed files with 1934 additions and 1 deletions

312
wfe-deno/src/ops/builder.rs Normal file
View File

@@ -0,0 +1,312 @@
use std::time::Duration;
use deno_core::op2;
use deno_core::OpState;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::ErrorBehavior;
use crate::state::WfeState;
/// Internal builder state that tracks the WorkflowBuilder and current step.
///
/// We don't use StepBuilder (pub(crate)) — instead we work directly with
/// WorkflowBuilder's public `steps` field, `add_step`, and `wire_outcome`.
pub struct JsBuilderState {
pub wb: WorkflowBuilder<serde_json::Value>,
pub current_step: Option<usize>,
}
/// Create a new WorkflowBuilder and return its handle.
#[op2(fast)]
#[smi]
pub fn op_builder_create(state: &mut OpState) -> u32 {
let wfe = state.borrow_mut::<WfeState>();
let id = wfe.alloc_builder_id();
wfe.builders.insert(
id,
JsBuilderState {
wb: WorkflowBuilder::<serde_json::Value>::new(),
current_step: None,
},
);
id
}
/// Add the first step via `start_with`.
#[op2(fast)]
pub fn op_builder_start_with(
state: &mut OpState,
#[smi] handle: u32,
#[string] step_type: String,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
if bs.current_step.is_some() {
return Err(deno_error::JsErrorBox::generic(
"start_with already called on this builder",
));
}
let step_id = bs.wb.add_step(&step_type);
bs.current_step = Some(step_id);
Ok(())
}
/// Chain the next step via `then`.
#[op2(fast)]
pub fn op_builder_then(
state: &mut OpState,
#[smi] handle: u32,
#[string] step_type: String,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let prev_id = bs
.current_step
.ok_or_else(|| deno_error::JsErrorBox::generic("call start_with before then"))?;
let next_id = bs.wb.add_step(&step_type);
bs.wb.wire_outcome(prev_id, next_id, None);
bs.current_step = Some(next_id);
Ok(())
}
/// Set the current step's name.
#[op2(fast)]
pub fn op_builder_name(
state: &mut OpState,
#[smi] handle: u32,
#[string] name: String,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let step_id = current_step(bs)?;
bs.wb.steps[step_id].name = Some(name);
Ok(())
}
/// Set the current step's JSON config.
#[op2]
pub fn op_builder_config(
state: &mut OpState,
#[smi] handle: u32,
#[serde] config: serde_json::Value,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let step_id = current_step(bs)?;
bs.wb.steps[step_id].step_config = Some(config);
Ok(())
}
/// Set the current step's error behavior.
#[op2]
pub fn op_builder_on_error(
state: &mut OpState,
#[smi] handle: u32,
#[serde] behavior: serde_json::Value,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let step_id = current_step(bs)?;
let eb = parse_error_behavior(&behavior)?;
bs.wb.steps[step_id].error_behavior = Some(eb);
Ok(())
}
/// Chain a delay step after the current step.
#[op2(fast)]
pub fn op_builder_delay(
state: &mut OpState,
#[smi] handle: u32,
#[number] ms: u64,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let prev_id = current_step(bs)?;
let delay_type = std::any::type_name::<wfe_core::primitives::delay::DelayStep>();
let next_id = bs.wb.add_step(delay_type);
bs.wb.steps[next_id].step_config = Some(serde_json::json!({
"duration_millis": ms,
}));
bs.wb.wire_outcome(prev_id, next_id, None);
bs.current_step = Some(next_id);
Ok(())
}
/// Chain a wait-for-event step after the current step.
#[op2(fast)]
pub fn op_builder_wait_for(
state: &mut OpState,
#[smi] handle: u32,
#[string] event_name: String,
#[string] event_key: String,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = get_builder(wfe, handle)?;
let prev_id = current_step(bs)?;
let wait_type = std::any::type_name::<wfe_core::primitives::wait_for::WaitForStep>();
let next_id = bs.wb.add_step(wait_type);
bs.wb.steps[next_id].step_config = Some(serde_json::json!({
"event_name": event_name,
"event_key": event_key,
}));
bs.wb.wire_outcome(prev_id, next_id, None);
bs.current_step = Some(next_id);
Ok(())
}
/// Build the workflow definition and return it as JSON. Consumes the builder.
#[op2]
#[serde]
pub fn op_builder_build(
state: &mut OpState,
#[smi] handle: u32,
#[string] id: String,
#[smi] version: u32,
) -> Result<serde_json::Value, deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let bs = wfe
.builders
.remove(&handle)
.ok_or_else(|| deno_error::JsErrorBox::generic("invalid builder handle"))?;
let def = bs.wb.build(&id, version);
serde_json::to_value(&def)
.map_err(|e| deno_error::JsErrorBox::generic(format!("serialization failed: {e}")))
}
/// Build the workflow definition and register it with the host. Consumes the builder.
#[op2]
pub async fn op_builder_register(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[smi] handle: u32,
#[string] id: String,
#[smi] version: u32,
) -> Result<(), deno_error::JsErrorBox> {
let (def, host) = {
let mut s = state.borrow_mut();
let wfe = s.borrow_mut::<WfeState>();
let bs = wfe
.builders
.remove(&handle)
.ok_or_else(|| deno_error::JsErrorBox::generic("invalid builder handle"))?;
let def = bs.wb.build(&id, version);
let host = wfe.host()?.clone();
(def, host)
};
host.register_workflow_definition(def).await;
Ok(())
}
fn get_builder(
wfe: &mut WfeState,
handle: u32,
) -> Result<&mut JsBuilderState, deno_error::JsErrorBox> {
wfe.builders
.get_mut(&handle)
.ok_or_else(|| deno_error::JsErrorBox::generic("invalid builder handle"))
}
fn current_step(bs: &JsBuilderState) -> Result<usize, deno_error::JsErrorBox> {
bs.current_step
.ok_or_else(|| deno_error::JsErrorBox::generic("no current step — call start_with first"))
}
fn parse_error_behavior(
value: &serde_json::Value,
) -> Result<ErrorBehavior, deno_error::JsErrorBox> {
match value.as_str() {
Some("suspend") => Ok(ErrorBehavior::Suspend),
Some("terminate") => Ok(ErrorBehavior::Terminate),
Some("compensate") => Ok(ErrorBehavior::Compensate),
_ => {
if let Some(retry) = value.get("retry") {
let interval = retry
.get("interval")
.and_then(|v| v.as_u64())
.unwrap_or(60_000);
let max_retries = retry
.get("maxRetries")
.and_then(|v| v.as_u64())
.unwrap_or(3) as u32;
Ok(ErrorBehavior::Retry {
interval: Duration::from_millis(interval),
max_retries,
})
} else {
Err(deno_error::JsErrorBox::generic(format!(
"invalid error behavior: {value}"
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parse_suspend_behavior() {
let eb = parse_error_behavior(&serde_json::json!("suspend")).unwrap();
assert!(matches!(eb, ErrorBehavior::Suspend));
}
#[test]
fn parse_terminate_behavior() {
let eb = parse_error_behavior(&serde_json::json!("terminate")).unwrap();
assert!(matches!(eb, ErrorBehavior::Terminate));
}
#[test]
fn parse_compensate_behavior() {
let eb = parse_error_behavior(&serde_json::json!("compensate")).unwrap();
assert!(matches!(eb, ErrorBehavior::Compensate));
}
#[test]
fn parse_retry_behavior() {
let eb = parse_error_behavior(
&serde_json::json!({"retry": {"interval": 5000, "maxRetries": 5}}),
)
.unwrap();
match eb {
ErrorBehavior::Retry {
interval,
max_retries,
} => {
assert_eq!(interval, Duration::from_millis(5000));
assert_eq!(max_retries, 5);
}
_ => panic!("expected Retry"),
}
}
#[test]
fn parse_retry_behavior_defaults() {
let eb = parse_error_behavior(&serde_json::json!({"retry": {}})).unwrap();
match eb {
ErrorBehavior::Retry {
interval,
max_retries,
} => {
assert_eq!(interval, Duration::from_millis(60_000));
assert_eq!(max_retries, 3);
}
_ => panic!("expected Retry"),
}
}
#[test]
fn parse_invalid_behavior_returns_error() {
let result = parse_error_behavior(&serde_json::json!("nonsense"));
assert!(result.is_err());
}
}

22
wfe-deno/src/ops/event.rs Normal file
View File

@@ -0,0 +1,22 @@
use deno_core::op2;
use deno_core::OpState;
use crate::state::WfeState;
/// Publish an event to the workflow host for matching subscriptions.
#[op2]
pub async fn op_publish_event(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] event_name: String,
#[string] event_key: String,
#[serde] data: serde_json::Value,
) -> Result<(), deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.publish_event(&event_name, &event_key, data)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("publish_event failed: {e}")))
}

62
wfe-deno/src/ops/host.rs Normal file
View File

@@ -0,0 +1,62 @@
use std::sync::Arc;
use deno_core::op2;
use deno_core::OpState;
use crate::state::WfeState;
/// Create a WorkflowHost with in-memory providers and store it in state.
#[op2(fast)]
pub fn op_host_create(state: &mut OpState) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
if wfe.host.is_some() {
return Err(deno_error::JsErrorBox::generic(
"WorkflowHost already created",
));
}
let persistence = Arc::new(wfe_core::test_support::InMemoryPersistenceProvider::new());
let lock = Arc::new(wfe_core::test_support::InMemoryLockProvider::new());
let queue = Arc::new(wfe_core::test_support::InMemoryQueueProvider::new());
let lifecycle = Arc::new(wfe_core::test_support::InMemoryLifecyclePublisher::new());
let host = wfe::WorkflowHostBuilder::new()
.use_persistence(persistence)
.use_lock_provider(lock)
.use_queue_provider(queue)
.use_lifecycle(lifecycle)
.build()
.map_err(|e| deno_error::JsErrorBox::generic(format!("Failed to build host: {e}")))?;
wfe.host = Some(Arc::new(host));
Ok(())
}
/// Start the WorkflowHost background tasks.
#[op2]
pub async fn op_host_start(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
) -> Result<(), deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.start()
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("Failed to start host: {e}")))
}
/// Stop the WorkflowHost gracefully.
#[op2]
pub async fn op_host_stop(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
) -> Result<(), deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.stop().await;
Ok(())
}

43
wfe-deno/src/ops/mod.rs Normal file
View File

@@ -0,0 +1,43 @@
pub mod builder;
pub mod event;
pub mod host;
pub mod step;
pub mod workflow;
deno_core::extension!(
wfe_deno_ext,
ops = [
// Host lifecycle
host::op_host_create,
host::op_host_start,
host::op_host_stop,
// Workflow management
workflow::op_start_workflow,
workflow::op_suspend_workflow,
workflow::op_resume_workflow,
workflow::op_terminate_workflow,
workflow::op_get_workflow,
// Builder
builder::op_builder_create,
builder::op_builder_start_with,
builder::op_builder_then,
builder::op_builder_name,
builder::op_builder_config,
builder::op_builder_on_error,
builder::op_builder_delay,
builder::op_builder_wait_for,
builder::op_builder_build,
builder::op_builder_register,
// Step execution bridge
step::op_register_step,
step::op_step_executor_poll,
step::op_step_executor_respond,
// Events
event::op_publish_event,
],
esm_entry_point = "ext:wfe-deno/bootstrap.js",
esm = [
"ext:wfe-deno/bootstrap.js" = "src/js/bootstrap.js",
"ext:wfe-deno/wfe.js" = "src/js/wfe.js",
],
);

107
wfe-deno/src/ops/step.rs Normal file
View File

@@ -0,0 +1,107 @@
use std::sync::Arc;
use deno_core::op2;
use deno_core::OpState;
use crate::bridge::JsStepBody;
use crate::state::WfeState;
/// Register a JS step type with the host.
///
/// On the Rust side this creates a factory that produces `JsStepBody` instances.
/// On the JS side the caller also registers the actual function via
/// `__wfe_registerStepFunction(stepType, fn)`.
#[op2]
pub async fn op_register_step(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] step_type: String,
) -> Result<(), deno_error::JsErrorBox> {
let (host, tx) = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
(wfe.host()?.clone(), wfe.step_request_tx.clone())
};
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
host.register_step_factory(
&step_type,
move || Box::new(JsStepBody::new(tx.clone(), counter.clone())),
)
.await;
Ok(())
}
/// Poll for a step execution request from the Rust executor.
///
/// This is an async op that blocks until a step needs to be executed.
/// Returns `{ requestId, stepType, context }` or null on shutdown.
#[op2]
#[serde]
pub async fn op_step_executor_poll(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
) -> Result<serde_json::Value, deno_error::JsErrorBox> {
// Take the receiver out of state (only the first call gets it).
let mut rx = {
let mut s = state.borrow_mut();
let wfe = s.borrow_mut::<WfeState>();
match wfe.step_request_rx.take() {
Some(rx) => rx,
None => {
return Err(deno_error::JsErrorBox::generic(
"step executor poll already active",
));
}
}
};
// Wait for the next request.
match rx.recv().await {
Some(req) => {
let request_id = req.request_id;
let result = serde_json::json!({
"requestId": request_id,
"stepType": req.step_type,
"context": req.context,
});
// Store the response sender for op_step_executor_respond.
{
let mut s = state.borrow_mut();
let wfe = s.borrow_mut::<WfeState>();
wfe.inflight.insert(request_id, req.response_tx);
// Put the receiver back so we can poll again.
wfe.step_request_rx = Some(rx);
}
Ok(result)
}
None => {
// Channel closed — shutdown.
Ok(serde_json::Value::Null)
}
}
}
/// Send a step execution result back to the Rust executor.
#[op2]
pub fn op_step_executor_respond(
state: &mut OpState,
#[smi] request_id: u32,
#[serde] result: Option<serde_json::Value>,
#[string] error: Option<String>,
) -> Result<(), deno_error::JsErrorBox> {
let wfe = state.borrow_mut::<WfeState>();
let tx = wfe.inflight.remove(&request_id).ok_or_else(|| {
deno_error::JsErrorBox::generic(format!("no inflight request with id {request_id}"))
})?;
let response = match error {
Some(err) => Err(err),
None => Ok(result.unwrap_or(serde_json::json!({"proceed": true}))),
};
// Ignore send failure — the executor may have timed out.
let _ = tx.send(response);
Ok(())
}

View File

@@ -0,0 +1,91 @@
use deno_core::op2;
use deno_core::OpState;
use crate::state::WfeState;
/// Start a new workflow instance. Returns the workflow instance ID.
#[op2]
#[string]
pub async fn op_start_workflow(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] definition_id: String,
#[smi] version: u32,
#[serde] data: serde_json::Value,
) -> Result<String, deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.start_workflow(&definition_id, version, data)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("start_workflow failed: {e}")))
}
/// Suspend a running workflow. Returns true if suspension succeeded.
#[op2]
pub async fn op_suspend_workflow(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] id: String,
) -> Result<bool, deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.suspend_workflow(&id)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("suspend_workflow failed: {e}")))
}
/// Resume a suspended workflow. Returns true if resumption succeeded.
#[op2]
pub async fn op_resume_workflow(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] id: String,
) -> Result<bool, deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.resume_workflow(&id)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("resume_workflow failed: {e}")))
}
/// Terminate a workflow. Returns true if termination succeeded.
#[op2]
pub async fn op_terminate_workflow(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] id: String,
) -> Result<bool, deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
host.terminate_workflow(&id)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("terminate_workflow failed: {e}")))
}
/// Get a workflow instance by ID. Returns the serialized WorkflowInstance.
#[op2]
#[serde]
pub async fn op_get_workflow(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[string] id: String,
) -> Result<serde_json::Value, deno_error::JsErrorBox> {
let host = {
let s = state.borrow();
let wfe = s.borrow::<WfeState>();
wfe.host()?.clone()
};
let instance = host
.get_workflow(&id)
.await
.map_err(|e| deno_error::JsErrorBox::generic(format!("get_workflow failed: {e}")))?;
serde_json::to_value(&instance)
.map_err(|e| deno_error::JsErrorBox::generic(format!("serialization failed: {e}")))
}