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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-lamps-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/builders": patch
---

Decode escaped workflowCode template literals before graph extraction so unicode-escape identifiers parse correctly.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ compiler. Remove that package from `serverExternalPackages` in your
`next.config` to silence the warning.
</Callout>

### Workflow Discovery in Next.js

`withWorkflow()` discovers workflows by scanning your Next.js entrypoints — App
Router `route`, `page`, and `layout` files (under `app/` or `src/app/`) and any
file under `pages/` or `src/pages/` — for `start()` calls imported from
`workflow/api`. The workflow and step files themselves can live anywhere (for
example `src/workflows/`); they are discovered transitively through imports, as
long as a `start()` call in an entrypoint statically reaches them.

<Callout type="info">
Call `start()` from server-side entrypoints, including Route Handlers and Server
Actions. Don't call workflow functions directly — that bypasses the workflow
runtime.
</Callout>

### Next.js Server Actions and `"use server"`

Don't put a top-level `"use server"` directive in modules imported by workflow
or step functions. Workflow transformation wraps imported modules in synchronous
initializers, and Next.js rejects a `"use server"` directive inside that wrapper
with errors like `Server Actions must be async functions`. Keep `"use server"`
on the files that define your Server Actions, and move shared logic into
separate modules that don't carry the directive.

### Monorepos and Workspace Imports

By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround:
Expand Down
48 changes: 48 additions & 0 deletions packages/builders/src/workflows-extractor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { extractWorkflowGraphs } from './workflows-extractor.js';

describe('extractWorkflowGraphs', () => {
let tempDir: string | undefined;

afterEach(async () => {
vi.restoreAllMocks();

if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});

it('parses workflowCode template literals with unicode-escape identifiers', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'workflow-builders-'));
const bundlePath = join(tempDir, 'workflow-bundle.js');
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});

await writeFile(
bundlePath,
[
'const workflowCode = `',
'function workflow() {',
' var DEBURR_MAP = new Map(Object.entries({\\\\u00C6: "Ae"}));',
' return DEBURR_MAP;',
'}',
'workflow.workflowId = "workflow//./input.js//workflow";',
'`;',
].join('\n')
);

await expect(extractWorkflowGraphs(bundlePath)).resolves.toEqual({
'./input.js': {
workflow: expect.objectContaining({
workflowId: 'workflow//./input.js//workflow',
}),
},
});
expect(consoleError).not.toHaveBeenCalled();
});
});
8 changes: 7 additions & 1 deletion packages/builders/src/workflows-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ function extractWorkflowCodeFromBundle(ast: Program): string | null {
decl.init
) {
if (decl.init.type === 'TemplateLiteral') {
return decl.init.quasis.map((q) => q.cooked || q.raw).join('');
return decl.init.quasis
.map((q) => decodeEscapedWorkflowCode(q.raw))
.join('');
}
if (decl.init.type === 'StringLiteral') {
return decl.init.value;
Expand All @@ -289,6 +291,10 @@ function extractWorkflowCodeFromBundle(ast: Program): string | null {
return null;
}

function decodeEscapedWorkflowCode(rawTemplateElement: string): string {
return rawTemplateElement.replace(/\\([\\`$])/g, '$1');
}

/**
* Extract step declarations using regex for speed
*/
Expand Down
Loading