Skip to main content

roder_dynamic_workflows/
script.rs

1use boa_engine::{Context, Source};
2
3use crate::model::{
4    RawWorkflowDefinition, WorkflowDefinition, WorkflowRuntimeError, WorkflowRuntimeErrorKind,
5    WorkflowRuntimeOptions, WorkflowRuntimeResult,
6};
7
8const DENIED_AMBIENT_PATTERNS: &[(&str, &str)] = &[
9    (
10        "import(",
11        "dynamic module loading is not available in workflow scripts",
12    ),
13    (
14        "import ",
15        "module loading is not available in workflow scripts",
16    ),
17    (
18        "require(",
19        "CommonJS loading is not available in workflow scripts",
20    ),
21    ("eval(", "eval is not available in workflow scripts"),
22    (
23        "Function(",
24        "dynamic function construction is not available in workflow scripts",
25    ),
26    (
27        "process.",
28        "ambient process access is not available in workflow scripts",
29    ),
30    (
31        "process[",
32        "ambient process access is not available in workflow scripts",
33    ),
34    (
35        "Deno.",
36        "ambient runtime access is not available in workflow scripts",
37    ),
38    (
39        "Bun.",
40        "ambient runtime access is not available in workflow scripts",
41    ),
42    (
43        "fetch(",
44        "network access is not available in workflow scripts",
45    ),
46    (
47        "XMLHttpRequest",
48        "network access is not available in workflow scripts",
49    ),
50    (
51        "WebSocket",
52        "network access is not available in workflow scripts",
53    ),
54    (
55        "setTimeout(",
56        "ambient timers are not available in workflow scripts",
57    ),
58    (
59        "setInterval(",
60        "ambient timers are not available in workflow scripts",
61    ),
62];
63
64pub fn parse_workflow_definition(
65    source: &str,
66    options: &WorkflowRuntimeOptions,
67) -> WorkflowRuntimeResult<WorkflowDefinition> {
68    preflight_script(source)?;
69    let mut context = new_context(options);
70    install_definition_prelude(&mut context)?;
71    eval_js(
72        &mut context,
73        source,
74        WorkflowRuntimeErrorKind::ScriptExecution,
75    )?;
76    let metadata = read_global_string(&mut context, "globalThis.__roderWorkflowMetadataJson")?
77        .filter(|value| !value.is_empty())
78        .ok_or_else(|| {
79            WorkflowRuntimeError::new(
80                WorkflowRuntimeErrorKind::MissingDefinition,
81                "script must call workflow.define(metadata, handler)",
82            )
83        })?;
84    parse_definition_json(&metadata, options)
85}
86
87pub(crate) fn preflight_script(source: &str) -> WorkflowRuntimeResult<()> {
88    for (pattern, message) in DENIED_AMBIENT_PATTERNS {
89        if source.contains(pattern) {
90            return Err(WorkflowRuntimeError::new(
91                WorkflowRuntimeErrorKind::DeniedAmbientApi,
92                *message,
93            ));
94        }
95    }
96    Ok(())
97}
98
99pub(crate) fn new_context(options: &WorkflowRuntimeOptions) -> Context {
100    let mut context = Context::default();
101    context
102        .runtime_limits_mut()
103        .set_loop_iteration_limit(options.max_loop_iterations);
104    context.runtime_limits_mut().set_recursion_limit(128);
105    context
106}
107
108pub(crate) fn install_definition_prelude(context: &mut Context) -> WorkflowRuntimeResult<()> {
109    eval_js(
110        context,
111        r#"
112Object.defineProperty(globalThis, "workflow", {
113  value: Object.freeze({
114    define(metadata, handler) {
115      if (typeof handler !== "function") {
116        throw new Error("workflow.define requires a function handler");
117      }
118      globalThis.__roderWorkflowMetadataJson = JSON.stringify(metadata || {});
119      globalThis.__roderWorkflowHandler = handler;
120    }
121  }),
122  writable: false,
123  configurable: false
124});
125"#,
126        WorkflowRuntimeErrorKind::ScriptExecution,
127    )
128}
129
130pub(crate) fn eval_js(
131    context: &mut Context,
132    source: &str,
133    kind: WorkflowRuntimeErrorKind,
134) -> WorkflowRuntimeResult<()> {
135    context
136        .eval(Source::from_bytes(source))
137        .map(|_| ())
138        .map_err(|err| classify_js_error(err.to_string(), kind))
139}
140
141pub(crate) fn read_global_string(
142    context: &mut Context,
143    expression: &str,
144) -> WorkflowRuntimeResult<Option<String>> {
145    let value = context
146        .eval(Source::from_bytes(expression))
147        .map_err(|err| {
148            classify_js_error(err.to_string(), WorkflowRuntimeErrorKind::ScriptExecution)
149        })?;
150    if value.is_null_or_undefined() {
151        return Ok(None);
152    }
153    let text = value
154        .to_string(context)
155        .map_err(|err| {
156            classify_js_error(err.to_string(), WorkflowRuntimeErrorKind::ScriptExecution)
157        })?
158        .to_std_string_escaped();
159    Ok(Some(text))
160}
161
162pub(crate) fn parse_definition_json(
163    metadata: &str,
164    options: &WorkflowRuntimeOptions,
165) -> WorkflowRuntimeResult<WorkflowDefinition> {
166    let raw: RawWorkflowDefinition = serde_json::from_str(metadata).map_err(|err| {
167        WorkflowRuntimeError::new(
168            WorkflowRuntimeErrorKind::InvalidMetadata,
169            format!("invalid workflow metadata: {err}"),
170        )
171    })?;
172    raw.into_definition(&options.limits)
173}
174
175pub(crate) fn classify_js_error(
176    message: String,
177    default_kind: WorkflowRuntimeErrorKind,
178) -> WorkflowRuntimeError {
179    if message.contains("loop iteration limit")
180        || message.contains("limit:")
181        || message.contains("Maximum call stack")
182    {
183        return WorkflowRuntimeError::new(WorkflowRuntimeErrorKind::LimitExceeded, message);
184    }
185    if message.contains("abort:") {
186        return WorkflowRuntimeError::new(WorkflowRuntimeErrorKind::Aborted, message);
187    }
188    WorkflowRuntimeError::new(default_kind, message)
189}