Files
vercel__workflow/packages/swc-plugin-workflow/src/lib.rs
Nathan Rajlich 73bf7be925 Change compiler ID generation logic to use Node.js import specifier (#899)
## Summary

This PR changes how the SWC compiler generates IDs for workflows, steps, and classes. Instead of using raw file paths, IDs are now based on **Node.js module specifiers** when the file belongs to a package (either in `node_modules` or a workspace package).

## Motivation

Previously, IDs were generated using file paths like `step//src/jobs/order.ts//fetchData`. This caused several issues:

1. **Package exports conditions**: When a package uses conditional exports (e.g., `"workflow"` vs `"default"` conditions in `package.json`), the same import specifier can resolve to different files. Using file paths meant IDs could differ based on which export condition was used.
2. **Cross-bundle consistency**: Classes serialized in one bundle couldn't be deserialized in another if the file paths differed.
3. **Version tracking**: No way to include package versions in IDs for cache invalidation.

## Changes

### New ID Format

IDs now use the format `{type}//{modulePath}//{identifier}` where `modulePath` is either:

- A **module specifier** like `point@0.0.1` or `@myorg/shared@1.2.3` for package files
- A **relative path** prefixed with `./` like `./src/jobs/order` for local app files

Examples:

- `step//workflow@4.0.1-beta.50//fetch` (SDK step)
- `step//./workflows/order//processOrder` (local step)
- `class//point@0.0.1//Point` (package class)
- `class//./src/models/User//User` (local class)

### New Module Specifier Resolution

Added `packages/builders/src/module-specifier.ts` which:

- Detects if a file is in `node_modules` or a workspace package
- Finds the nearest `package.json` and extracts name/version
- Returns the module specifier for the SWC plugin to use

### SWC Plugin Changes

- Added `moduleSpecifier` option to plugin config
- Updated `naming.rs` to support both module specifiers and relative paths
- Added `get_module_path()` helper that uses specifier when available, falls back to `./filename` format

### Special Cases

- **Builtin functions** (`__builtin_*`): Continue to use just the function name as the ID for stable, version-independent lookup from the workflow VM runtime.

## Testing

- Updated all 125+ SWC plugin test fixtures to use new ID format
- Added tests for module specifier resolution
- Added tests for Windows path normalization in naming

## Breaking Changes

This is technically a breaking change for any persisted workflow runs that reference the old ID format. However, since IDs are internal implementation details and not user-facing, this should not affect end users.

## Files Changed

- `packages/builders/src/module-specifier.ts` - **NEW**: Module specifier resolution logic
- `packages/builders/src/apply-swc-transform.ts` - Pass module specifier to SWC plugin
- `packages/builders/src/base-builder.ts` - Use `getImportPath` for virtual entry imports
- `packages/swc-plugin-workflow/transform/src/lib.rs` - Accept and use module specifier
- `packages/swc-plugin-workflow/transform/src/naming.rs` - New ID formatting with module paths
- `packages/swc-plugin-workflow/spec.md` - Updated documentation
- `packages/core/e2e/e2e.test.ts` - Updated test assertions for new ID format
2026-02-04 14:23:02 -08:00

92 lines
3.3 KiB
Rust

#![allow(clippy::not_unsafe_ptr_arg_deref)]
use serde::Deserialize;
use std::path::Path;
use swc_core::{
ecma::{ast::*, visit::*},
plugin::{plugin_transform, proxies::TransformPluginProgramMetadata},
};
use swc_workflow::{StepTransform, TransformMode};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct WasmConfig {
mode: TransformMode,
/// The module specifier to use for ID generation.
///
/// This should be the canonical import specifier for this file, for example:
/// - "point@0.0.1" for a class from the `point` npm package
/// - "@myorg/shared@1.2.3" for a scoped package
///
/// If not provided, the plugin will use "./{relative_path}" format (e.g., "./src/models/Point").
///
/// This enables stable IDs across different export conditions in package.json,
/// where the same package specifier may resolve to different files depending on
/// the condition (e.g., "workflow" vs "default").
module_specifier: Option<String>,
}
#[plugin_transform]
pub fn process_transform(
mut program: Program,
metadata: TransformPluginProgramMetadata,
) -> Program {
let plugin_config: WasmConfig = serde_json::from_str(
&metadata
.get_transform_plugin_config()
.expect("failed to get plugin config for workflow transform"),
)
.expect("Should provide plugin config");
let filename = metadata
.get_context(&swc_core::plugin::metadata::TransformPluginMetadataContextKind::Filename)
.unwrap_or_else(|| "unknown".to_string());
// Try to get cwd and make the path relative
let cwd =
metadata.get_context(&swc_core::plugin::metadata::TransformPluginMetadataContextKind::Cwd);
let relative_filename = if let Some(cwd) = cwd {
let cwd_path = Path::new(&cwd);
let file_path = Path::new(&filename);
// Try to strip the cwd prefix to make it relative
if let Ok(relative) = file_path.strip_prefix(cwd_path) {
relative.to_string_lossy().to_string()
} else {
// Find common ancestor path
let cwd_components: Vec<_> = cwd_path.components().collect();
let file_components: Vec<_> = file_path.components().collect();
// Find the longest common prefix
let common_len = cwd_components
.iter()
.zip(file_components.iter())
.take_while(|(a, b)| a == b)
.count();
if common_len > 0 {
// Build relative path from the common ancestor
let remaining_file: Vec<_> = file_components.into_iter().skip(common_len).collect();
let relative_path = remaining_file.into_iter().collect::<std::path::PathBuf>();
relative_path.to_string_lossy().to_string()
} else {
filename
}
}
} else {
filename
};
// Normalize path separators to forward slashes for consistent workflow IDs across platforms
let normalized_filename = relative_filename.replace('\\', "/");
let mut visitor = StepTransform::new(
plugin_config.mode,
normalized_filename,
plugin_config.module_specifier,
);
program.visit_mut_with(&mut visitor);
program
}