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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Change compiler ID generation logic to use Node.js import specifier by TooTallNate · Pull Request #899 · vercel/workflow · GitHub
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
14 changes: 14 additions & 0 deletions .changeset/rich-symbols-fold.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
"@workflow/rollup": patch
"@workflow/next": patch
---

Change compiler ID generation logic to use Node.js import specifiers

IDs for workflows, steps, and classes now use module specifiers:
- Local files use `./path/to/file` format instead of `path/to/file.ext`
- Package files use `packageName@version` format (e.g., `workflow@4.0.1`)

This enables stable IDs across different package.json export conditions.
15 changes: 13 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

const require = createRequire(import.meta.url);

Expand DownExpand Up@@ -67,6 +68,16 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Resolve module specifier for packages (node_modules or workspace packages)
const projectRoot = process.cwd();
const absoluteFilename = isAbsolute(filename)
? filename
: join(projectRoot, filename);
const { moduleSpecifier } = resolveModuleSpecifier(
absoluteFilename,
projectRoot
);

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All@@ -88,7 +99,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
}
: undefined,
transform: {
Expand Down
28 changes: 28 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
Expand DownExpand Up@@ -319,7 +320,20 @@ export abstract class BaseBuilder {
});

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with the appropriate conditions
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand DownExpand Up@@ -505,7 +519,21 @@ export abstract class BaseBuilder {
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
// For workspace/node_modules packages, uses the package name so esbuild
// will resolve through package.json exports with conditions: ['workflow']
const createImport = (file: string) => {
const { importPath, isPackage } = getImportPath(
file,
this.config.workingDir
);

if (isPackage) {
// Use package name - esbuild will resolve via package.json exports
// and apply the 'workflow' condition
return `import '${importPath}';`;
}

// Local app file - use relative path
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
Expand Down
7 changes: 7 additions & 0 deletions packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@ export {
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
getImportPath,
type ImportPathResult,
type ModuleSpecifierResult,
resolveModuleSpecifier,
} from './module-specifier.js';
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export {
createPseudoPackagePlugin,
Expand Down
249 changes: 249 additions & 0 deletions packages/builders/src/module-specifier.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';

/**
* Result of resolving a module specifier for a file.
*/
export interface ModuleSpecifierResult {
/**
* The module specifier to use for ID generation.
* - For packages: "{name}@{version}" (e.g., "point@1.0.0", "@myorg/shared@2.0.0")
* - For local files: undefined (plugin will use default "./relative/path" format)
*/
moduleSpecifier: string | undefined;
}

/**
* Cache for package.json lookups to avoid repeated filesystem reads.
* Maps directory path to parsed package.json or null if not found.
*/
const packageJsonCache = new Map<
string,
{ name: string; version: string } | null
>();

/**
* Find and read the nearest package.json for a given file path.
* Results are cached for performance.
*/
function findPackageJson(
filePath: string
): { name: string; version: string } | null {
let dir = dirname(filePath);

while (dir !== dirname(dir)) {
// Check cache first
const cached = packageJsonCache.get(dir);
if (cached !== undefined) {
return cached;
}

const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const parsed = JSON.parse(content);
if (parsed.name && parsed.version) {
const result = { name: parsed.name, version: parsed.version };
packageJsonCache.set(dir, result);
return result;
}
} catch {
// Invalid JSON or missing fields, continue searching
}
}

packageJsonCache.set(dir, null);
dir = dirname(dir);
}

return null;
}

/**
* Check if a file path is inside node_modules.
*/
function isInNodeModules(filePath: string): boolean {
const normalizedPath = filePath.split(sep).join('/');
return normalizedPath.includes('/node_modules/');
}

/**
* Check if a file path is inside a workspace package.
* This is a heuristic - we check if the file is in a directory with a package.json
* that has a "name" field, but is NOT in node_modules.
*/
function isWorkspacePackage(filePath: string, projectRoot: string): boolean {
if (isInNodeModules(filePath)) {
return false;
}

const pkg = findPackageJson(filePath);
if (!pkg) {
return false;
}

// Check if the package.json is not the root package.json
// Use resolve() to normalize paths for cross-platform comparison
const rootPkgPath = resolve(projectRoot, 'package.json');

// Walk up to find the package.json directory
let dir = dirname(filePath);
while (dir !== dirname(dir)) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
// If this is the root package.json, it's not a workspace package
// Use resolve() to normalize both paths before comparison
if (resolve(pkgPath) === rootPkgPath) {
return false;
}
// Found a package.json that's not the root - it's a workspace package
return true;
}
dir = dirname(dir);
}

return false;
}

/**
* Resolve the module specifier for a file.
*
* @param filePath - Absolute path to the file being transformed
* @param projectRoot - Absolute path to the project root (usually process.cwd())
* @returns The module specifier result
*
* @example
* // File in node_modules
* resolveModuleSpecifier('/project/node_modules/point/dist/index.js', '/project')
* // => { moduleSpecifier: 'point@1.0.0' }
*
* @example
* // File in workspace package
* resolveModuleSpecifier('/project/packages/shared/src/utils.ts', '/project')
* // => { moduleSpecifier: '@myorg/shared@0.0.0' }
*
* @example
* // Local app file
* resolveModuleSpecifier('/project/src/workflows/order.ts', '/project')
* // => { moduleSpecifier: undefined }
*/
export function resolveModuleSpecifier(
filePath: string,
projectRoot: string
): ModuleSpecifierResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (!inNodeModules && !inWorkspace) {
// Local app file - use default relative path format
return { moduleSpecifier: undefined };
}

// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (!pkg) {
// Couldn't find package.json - fall back to default
return { moduleSpecifier: undefined };
}

// Return the module specifier as "name@version"
return {
moduleSpecifier: `${pkg.name}@${pkg.version}`,
};
}

/**
* Clear the package.json cache. Useful for testing or when package.json files may have changed.
*/
export function clearModuleSpecifierCache(): void {
packageJsonCache.clear();
}

/**
* Result of resolving an import path for a file.
*/
export interface ImportPathResult {
/**
* The import path to use.
* - For workspace packages: the package name (e.g., "@myorg/shared")
* - For node_modules packages: the package name
* - For local files: a relative path (e.g., "./src/workflows/order.ts")
*/
importPath: string;

/**
* Whether this file is from a package (workspace or node_modules).
* When true, the import should go through package resolution which respects export conditions.
*/
isPackage: boolean;
}

/**
* Get the import path to use for a file in a bundle's virtual entry.
*
* For workspace packages and node_modules packages, returns the package name
* so that bundler resolution will respect package.json exports and conditions.
*
* For local app files, returns a relative path.
*
* @param filePath - Absolute path to the file
* @param projectRoot - Absolute path to the project root
* @returns The import path and whether it's a package
*
* @example
* // Workspace package
* getImportPath('/project/packages/shared/src/index.ts', '/project')
* // => { importPath: '@myorg/shared', isPackage: true }
*
* @example
* // Local app file
* getImportPath('/project/src/workflows/order.ts', '/project')
* // => { importPath: './src/workflows/order.ts', isPackage: false }
*/
export function getImportPath(
filePath: string,
projectRoot: string
): ImportPathResult {
// Check if file is in node_modules or a workspace package
const inNodeModules = isInNodeModules(filePath);
const inWorkspace =
!inNodeModules && isWorkspacePackage(filePath, projectRoot);

if (inNodeModules || inWorkspace) {
// Find the package.json for this file
const pkg = findPackageJson(filePath);
if (pkg) {
return {
importPath: pkg.name,
isPackage: true,
};
}
}

// Local app file - use relative path
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
const normalizedFilePath = filePath.replace(/\\/g, '/');

let relativePath: string;
if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) {
relativePath = normalizedFilePath.substring(
normalizedProjectRoot.length + 1
);
} else {
// File is outside project root, use the full path segments after common ancestor
relativePath = relative(projectRoot, filePath).replace(/\\/g, '/');
}

// Ensure relative paths start with ./
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}

return {
importPath: relativePath,
isPackage: false,
};
}
18 changes: 11 additions & 7 deletions packages/core/e2e/e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,13 +163,17 @@ describe('e2e', () => {
input: [123],
output: 133,
});
// In local vs. vercel backends, the workflow name is different, so we check for either,
// since this test runs against both. Also different workbenches have different directory structures.
expect(json.workflowName).toBeOneOf([
`workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//${workflow.workflowFile}//${workflow.workflowFn}`,
`workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`,
]);
// Workflow ID format: workflow//./{path-without-extension}//{functionName}
// Different workbenches have different directory structures:
// - workflows/ (standard)
// - src/workflows/ (some frameworks)
// - example/workflows/ (example app)
const fileWithoutExt = workflow.workflowFile.replace(/\.tsx?$/, '');
expect(json.workflowName).toMatch(
new RegExp(
`^workflow//\\./(?:src/|example/)?${fileWithoutExt}//${workflow.workflowFn}$`
)
);
});

const isNext = process.env.APP_NAME?.includes('nextjs');
Expand Down
Loading