Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sync-step-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/swc-plugin": patch
---

Restore export validation for file-level `"use step"` files: only function exports (sync or async) are allowed; non-function exports (constants, classes, re-exports) emit an error
27 changes: 17 additions & 10 deletions packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1060,26 +1060,33 @@ The plugin emits errors for invalid usage:

| Error | Description |
|-------|-------------|
| Non-async function | Functions with `"use step"` or `"use workflow"` must be async |
| Non-async workflow function | Functions with `"use workflow"` must be async (step functions may be sync) |
| Instance methods with `"use workflow"` | Only static methods can have `"use workflow"` (not instance methods) |
| Getters with `"use workflow"` | Getters cannot be marked with `"use workflow"` |
| Misplaced directive | Directive must be at top of file or start of function body |
| Conflicting directives | Cannot have both `"use step"` and `"use workflow"` at module level |
| Invalid exports | Module-level directive files can only export async functions |
| Invalid exports (`"use workflow"`) | Module-level `"use workflow"` files can only export async functions |
| Invalid exports (`"use step"`) | Module-level `"use step"` files can only export functions (sync or async) |
| Misspelled directive | Detects typos like `"use steps"` or `"use workflows"` |

---

## Supported Function Forms

The plugin supports various function declaration styles:

- `async function name() { "use step"; }` - Function declaration
- `const name = async () => { "use step"; }` - Arrow function with const
- `let name = async () => { "use step"; }` - Arrow function with let
- `var name = async () => { "use step"; }` - Arrow function with var
- `const name = async function() { "use step"; }` - Function expression
- `{ async method() { "use step"; } }` - Object method
The plugin supports various function declaration styles. Step functions may be synchronous or asynchronous. Workflow functions must be async.

- `async function name() { "use step"; }` - Async function declaration
- `function name() { "use step"; }` - Sync function declaration
- `const name = async () => { "use step"; }` - Async arrow function
Comment thread
TooTallNate marked this conversation as resolved.
- `const name = () => { "use step"; }` - Sync arrow function
- `let name = async () => { "use step"; }` - Async arrow function with let
- `let name = () => { "use step"; }` - Sync arrow function with let
- `var name = async () => { "use step"; }` - Async arrow function with var
- `var name = () => { "use step"; }` - Sync arrow function with var
- `const name = async function() { "use step"; }` - Async function expression
- `const name = function() { "use step"; }` - Sync function expression
- `{ async method() { "use step"; } }` - Async object method
- `{ method() { "use step"; } }` - Sync object method
- `{ nested: { execute: async () => { "use step"; } } }` - Nested object property
- `static async method() { "use step"; }` - Static class method
- `async method() { "use step"; }` - Instance class method (requires custom serialization)
Expand Down
121 changes: 112 additions & 9 deletions packages/swc-plugin-workflow/transform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,17 @@ fn emit_error(error: WorkflowErrorKind) {
),
WorkflowErrorKind::InvalidExport { span, directive } => (
span,
format!(
"Only async functions can be exported from a \"{}\" file",
directive
),
if directive == "use step" {
format!(
"Only functions can be exported from a \"{}\" file",
directive
)
} else {
format!(
"Only async functions can be exported from a \"{}\" file",
directive
)
},
),
};

Expand Down Expand Up @@ -4054,8 +4061,6 @@ impl StepTransform {
}
}

// Previously validated that step functions must be async. The restriction

// Check if a function should be treated as a step function
fn should_transform_function(&self, function: &Function, is_exported: bool) -> bool {
let has_directive = self.has_use_step_directive(&function.body);
Expand Down Expand Up @@ -6051,9 +6056,107 @@ impl VisitMut for StepTransform {
let mut items_to_insert = Vec::new();

for (i, item) in items.iter_mut().enumerate() {
// Validate exports if we have a file-level workflow directive.
// Step files allow any exports (sync or async), but workflow files
// require exported functions to be async.
// Validate exports for file-level step directives.
// Step files allow sync or async function exports but reject
// non-function exports (constants, classes, re-exports) which
// can pull Node-only code into the workflow/client bundles.
if self.has_file_step_directive {
match item {
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
match &export.decl {
Decl::Fn(_) => {
// Sync or async function declarations are allowed
}
Decl::Var(var_decl) => {
for decl in &var_decl.decls {
match &decl.init {
Some(init) => match &**init {
Expr::Fn(_) | Expr::Arrow(_) => {
// Function/arrow expressions are allowed
}
_ => {
emit_error(WorkflowErrorKind::InvalidExport {
span: export.span,
directive: "use step",
});
}
},
None => {
// Uninitialized exports are not functions
emit_error(WorkflowErrorKind::InvalidExport {
span: export.span,
directive: "use step",
});
}
}
}
}
Comment thread
TooTallNate marked this conversation as resolved.
Decl::TsInterface(_)
| Decl::TsTypeAlias(_)
| Decl::TsEnum(_)
| Decl::TsModule(_) => {
// TypeScript declarations are okay
}
_ => {
emit_error(WorkflowErrorKind::InvalidExport {
span: export.span,
directive: "use step",
});
}
}
}
ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named)) => {
// Re-exports (`export { x } from '...'`) are not allowed.
// Local named exports (`export { x }`) are also rejected
// because we cannot statically verify the binding is a function.
if named.src.is_some() || !named.specifiers.is_empty() {
emit_error(WorkflowErrorKind::InvalidExport {
span: named.span,
directive: "use step",
});
}
Comment thread
TooTallNate marked this conversation as resolved.
}
ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(default)) => {
match &default.decl {
DefaultDecl::Fn(_) => {
// Sync or async function declarations are allowed
}
DefaultDecl::TsInterfaceDecl(_) => {
// TypeScript interface is okay
}
_ => {
emit_error(WorkflowErrorKind::InvalidExport {
span: default.span,
directive: "use step",
});
}
}
}
ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(expr)) => {
match &*expr.expr {
Expr::Fn(_) | Expr::Arrow(_) => {
// Function/arrow expressions are allowed
}
_ => {
emit_error(WorkflowErrorKind::InvalidExport {
span: expr.span,
directive: "use step",
});
}
}
}
ModuleItem::ModuleDecl(ModuleDecl::ExportAll(export_all)) => {
emit_error(WorkflowErrorKind::InvalidExport {
span: export_all.span,
directive: "use step",
});
}
_ => {}
}
}

// Validate exports for file-level workflow directives.
// Workflow files require exported functions to be async.
if self.has_file_workflow_directive {
match item {
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use step';

// Error: default class export
export default class MyClass {
method() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Error: default class export
export default class MyClass {
method() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
x Only functions can be exported from a "use step" file
,-[input.js:4:1]
3 | // Error: default class export
4 | ,-> export default class MyClass {
5 | | method() {}
6 | `-> }
`----
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Error: default class export
export default class MyClass {
method() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
x Only functions can be exported from a "use step" file
,-[input.js:4:1]
3 | // Error: default class export
4 | ,-> export default class MyClass {
5 | | method() {}
6 | `-> }
`----
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use step';
// Error: default class export
export default class MyClass {
method() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
x Only functions can be exported from a "use step" file
,-[input.js:4:1]
3 | // Error: default class export
4 | ,-> export default class MyClass {
5 | | method() {}
6 | `-> }
`----
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ export class MyClass {
method() {}
}
export * from './other';
export let uninitVar;

// This is ok - sync functions are allowed in "use step" files
// Local named exports also error (can't verify binding is a function)
const helper = 'not a function';
export { helper };

// Re-export with specifiers also errors
export { something } from './re-export';

// These are ok - sync and async functions are allowed in "use step" files
export function syncFunc() {
return 'allowed';
}

// This is ok
export async function validStep() {
return 'allowed';
}

export const arrowStep = () => 'allowed';
export const asyncArrow = async () => 'allowed';
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
/**__internal_workflows{"steps":{"input.js":{"syncFunc":{"stepId":"step//./input//syncFunc"},"validStep":{"stepId":"step//./input//validStep"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"asyncArrow":{"stepId":"step//./input//asyncArrow"},"syncFunc":{"stepId":"step//./input//syncFunc"},"validStep":{"stepId":"step//./input//validStep"}}}}*/;
// These should all error - not functions
export const value = 42;
export class MyClass {
method() {}
}
export * from './other';
// This is ok - sync functions are allowed in "use step" files
export let uninitVar;
// Local named exports also error (can't verify binding is a function)
const helper = 'not a function';
export { helper };
// Re-export with specifiers also errors
export { something } from './re-export';
// These are ok - sync and async functions are allowed in "use step" files
export function syncFunc() {
return 'allowed';
}
syncFunc.stepId = "step//./input//syncFunc";
// This is ok
export async function validStep() {
return 'allowed';
}
validStep.stepId = "step//./input//validStep";
export const arrowStep = ()=>'allowed';
arrowStep.stepId = "step//./input//arrowStep";
export const asyncArrow = async ()=>'allowed';
asyncArrow.stepId = "step//./input//asyncArrow";
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
x Only functions can be exported from a "use step" file
,-[input.js:4:1]
3 | // These should all error - not functions
4 | export const value = 42;
: ^^^^^^^^^^^^^^^^^^^^^^^^
5 | export class MyClass {
`----
x Only functions can be exported from a "use step" file
,-[input.js:5:1]
4 | export const value = 42;
5 | ,-> export class MyClass {
6 | | method() {}
7 | `-> }
8 | export * from './other';
`----
x Only functions can be exported from a "use step" file
,-[input.js:8:1]
7 | }
8 | export * from './other';
: ^^^^^^^^^^^^^^^^^^^^^^^^
9 | export let uninitVar;
`----
x Only functions can be exported from a "use step" file
,-[input.js:9:1]
8 | export * from './other';
9 | export let uninitVar;
: ^^^^^^^^^^^^^^^^^^^^^
`----
x Only functions can be exported from a "use step" file
,-[input.js:13:1]
12 | const helper = 'not a function';
13 | export { helper };
: ^^^^^^^^^^^^^^^^^^
`----
x Only functions can be exported from a "use step" file
,-[input.js:16:1]
15 | // Re-export with specifiers also errors
16 | export { something } from './re-export';
: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
`----
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
/**__internal_workflows{"steps":{"input.js":{"syncFunc":{"stepId":"step//./input//syncFunc"},"validStep":{"stepId":"step//./input//validStep"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"asyncArrow":{"stepId":"step//./input//asyncArrow"},"syncFunc":{"stepId":"step//./input//syncFunc"},"validStep":{"stepId":"step//./input//validStep"}}}}*/;
// These should all error - not functions
export const value = 42;
export class MyClass {
method() {}
}
export * from './other';
// This is ok - sync functions are allowed in "use step" files
export let uninitVar;
// Local named exports also error (can't verify binding is a function)
const helper = 'not a function';
export { helper };
// Re-export with specifiers also errors
export { something } from './re-export';
// These are ok - sync and async functions are allowed in "use step" files
export function syncFunc() {
return 'allowed';
}
Expand All @@ -14,7 +20,6 @@ export function syncFunc() {
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(syncFunc, "step//./input//syncFunc");
// This is ok
export async function validStep() {
return 'allowed';
}
Expand All @@ -23,3 +28,15 @@ export async function validStep() {
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(validStep, "step//./input//validStep");
export const arrowStep = ()=>'allowed';
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(arrowStep, "step//./input//arrowStep");
export const asyncArrow = async ()=>'allowed';
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(asyncArrow, "step//./input//asyncArrow");
Loading
Loading