Skip to content

Commit 4c2993b

Browse files
authored
Emit new Worker config for same-worker Workflows (#15856)
1 parent 8dc53ae commit 4c2993b

41 files changed

Lines changed: 1849 additions & 79 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"miniflare": minor
3+
"@cloudflare/workflows-shared": minor
4+
"@cloudflare/vitest-plugin": minor
5+
"@cloudflare/vite-plugin": minor
6+
"wrangler": minor
7+
---
8+
9+
Support Workflows declared in `exports` on `ctx.exports` in local development
10+
11+
A Workflow declared in a Worker's `exports` is now available on `ctx.exports` in `wrangler dev`, the Vite plugin and the Vitest plugin, with the same API as a Workflow binding:
12+
13+
```ts
14+
const instance = await ctx.exports.MyWorkflow.create({
15+
params: { name: "World" },
16+
});
17+
```
18+
19+
`ctx.exports` and `workflows` bindings with the same Workflow `name` share their instances, including instances created before the Workflow was declared in `exports`. Two Workers can't export the same Workflow name, and a binding to an exported Workflow must refer to the Worker and class that export it. `getPlatformProxy()` ignores Workflows declared in `exports`, since it doesn't run the Worker's code.
20+
21+
`wrangler workflows` commands run with `--local` also work with Workflows declared only in `exports`, without a `workflows` binding.
22+
23+
In the Vitest plugin, `introspectWorkflow()` and `introspectWorkflowInstance()` still need a Workflow binding, and now explain how to add one when passed a Workflow from `ctx.exports`. Instances created through `ctx.exports` are introspected too. A `workflows` binding whose `script_name` is the Worker's own name now resolves to the Worker itself again.

‎fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,20 @@ describe("getPlatformProxy - env", () => {
312312
"
313313
`);
314314
});
315+
316+
it("ignores Workflows declared in `exports` and doesn't crash", async ({
317+
expect,
318+
}) => {
319+
const { dispose } = await getPlatformProxy<Env>({
320+
configPath: path.join(
321+
__dirname,
322+
"..",
323+
"wrangler_workflow_exports.jsonc"
324+
),
325+
});
326+
await dispose();
327+
expect(warn).not.toHaveBeenCalled();
328+
});
315329
});
316330

317331
describe("with a target environment", () => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "workflow-exports-test",
3+
"main": "src/index.ts",
4+
// `index.ts` doesn't actually export `MyWorkflow`, but `getPlatformProxy()`
5+
// discards all user code anyway, so it must ignore the Workflow export.
6+
"compatibility_date": "2026-08-28",
7+
"exports": {
8+
"MyWorkflow": { "type": "workflow", "name": "my-workflow" },
9+
},
10+
}

‎fixtures/vitest-plugin-examples/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"typegen:web-assembly": "wrangler types ./web-assembly/src/env.d.ts -c ./web-assembly/wrangler.jsonc --no-include-runtime",
3131
"typegen:workers-assets": "wrangler types ./workers-assets/src/env.d.ts -c ./workers-assets/wrangler.jsonc --no-include-runtime",
3232
"typegen:workers-assets-no-dir": "wrangler types ./workers-assets-no-dir/src/env.d.ts -c ./workers-assets-no-dir/wrangler.jsonc --no-include-runtime",
33-
"typegen:workflows": "wrangler types ./workflows/src/env.d.ts -c ./workflows/wrangler.jsonc --no-include-runtime"
33+
"typegen:workflows": "wrangler types ./workflows/src/env.d.ts -c ./workflows/wrangler.jsonc --no-include-runtime",
34+
"typegen:workflows-exports": "wrangler types ./workflows-exports/src/env.d.ts -c ./workflows-exports/wrangler.jsonc --no-include-runtime"
3435
},
3536
"devDependencies": {
3637
"@better-auth/stripe": "^1.4.6",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# 🔁 workflows-exports
2+
3+
This Worker declares a `GreetingWorkflow` in the `exports` field of `wrangler.jsonc` instead of the `workflows` array. It has no binding, so the Worker reaches it through `ctx.exports.GreetingWorkflow`.
4+
5+
`introspectWorkflow()` needs a Workflow binding, so the Vitest config adds a test-only binding to the same Workflow. Instances created through `ctx.exports` are introspected too.
6+
7+
| Test | Overview |
8+
| --------------------------------------- | ---------------------------------------------------------------------------------------- |
9+
| [exports.test.ts](test/exports.test.ts) | Runs the Workflow through `ctx.exports`, and introspects it through a test-only binding. |
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* eslint-disable */
2+
// Generated by Wrangler by running `wrangler types --config=./workflows-exports/wrangler.jsonc --include-runtime=false ./workflows-exports/src/env.d.ts` (hash: 0c8cd5ae12176ebab8337cbfef98b516)
3+
interface __BaseEnv_Env {}
4+
declare namespace Cloudflare {
5+
interface GlobalProps {
6+
mainModule: typeof import("./index");
7+
}
8+
interface Env extends __BaseEnv_Env {}
9+
}
10+
interface Env extends __BaseEnv_Env {}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
WorkflowEntrypoint,
3+
WorkflowEvent,
4+
WorkflowStep,
5+
} from "cloudflare:workers";
6+
7+
type Params = { name: string };
8+
9+
export class GreetingWorkflow extends WorkflowEntrypoint<Env, Params> {
10+
async run(event: Readonly<WorkflowEvent<Params>>, step: WorkflowStep) {
11+
return await step.do("greet", async () => `Hello, ${event.payload.name}!`);
12+
}
13+
}
14+
15+
export default {
16+
async fetch(request, _env, ctx) {
17+
const url = new URL(request.url);
18+
const id = url.searchParams.get("id");
19+
if (id !== null) {
20+
const instance = await ctx.exports.GreetingWorkflow.get(id);
21+
return Response.json(await instance.status());
22+
}
23+
24+
const instance = await ctx.exports.GreetingWorkflow.create({
25+
params: { name: url.searchParams.get("name") ?? "World" },
26+
});
27+
return Response.json({ id: instance.id });
28+
},
29+
} satisfies ExportedHandler<Env>;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "../../tsconfig.workerd.json",
3+
"include": ["./**/*.ts"]
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare namespace Cloudflare {
2+
interface Env {
3+
GREETING_WORKFLOW: Workflow; // Defined in `vitest.config.ts`
4+
}
5+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
introspectWorkflow,
3+
introspectWorkflowInstance,
4+
} from "cloudflare:test";
5+
import { env, exports } from "cloudflare:workers";
6+
import { it } from "vitest";
7+
8+
it("runs a Workflow declared in `exports` through `ctx.exports`", async ({
9+
expect,
10+
}) => {
11+
const response = await exports.default.fetch(
12+
"https://example.com/?name=Workers"
13+
);
14+
const { id } = await response.json<{ id: string }>();
15+
16+
await expect
17+
.poll(async () => {
18+
const response = await exports.default.fetch(
19+
`https://example.com/?id=${id}`
20+
);
21+
return response.json();
22+
})
23+
.toMatchObject({ status: "complete", output: "Hello, Workers!" });
24+
});
25+
26+
it("introspects instances created through `ctx.exports` with a binding", async ({
27+
expect,
28+
}) => {
29+
await using introspector = await introspectWorkflow(env.GREETING_WORKFLOW);
30+
await introspector.modifyAll(async (m) => {
31+
await m.mockStepResult({ name: "greet" }, "Hello, mock!");
32+
});
33+
34+
await exports.default.fetch("https://example.com/");
35+
36+
const instances = await introspector.get();
37+
expect(instances).toHaveLength(1);
38+
await instances[0].waitForStatus("complete");
39+
expect(await instances[0].getOutput()).toBe("Hello, mock!");
40+
});
41+
42+
it("rejects introspecting a Workflow on `ctx.exports`", async ({ expect }) => {
43+
await expect(introspectWorkflow(exports.GreetingWorkflow)).rejects.toThrow(
44+
"`introspectWorkflow()` needs a Workflow binding from `env`"
45+
);
46+
await expect(
47+
introspectWorkflowInstance(exports.GreetingWorkflow, "an-id")
48+
).rejects.toThrow(
49+
"`introspectWorkflowInstance()` needs a Workflow binding from `env`"
50+
);
51+
});

0 commit comments

Comments
 (0)