Skip to content

[core] Refactor getWorld interface to be asynchronous#942

Merged
VaguelySerious merged 26 commits into
mainfrom
peter/async-world
Apr 9, 2026
Merged

[core] Refactor getWorld interface to be asynchronous#942
VaguelySerious merged 26 commits into
mainfrom
peter/async-world

Conversation

@VaguelySerious
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious commented Feb 5, 2026

This is based on community PR #836 which highlighted the need for an asynchronous interface so that we can use ESM dynamic imports.

Fixes #812
Fixes #825

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 5, 2026

🦋 Changeset detected

Latest commit: 2653faf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@workflow/core Patch
@workflow/cli Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
workflow Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch
@workflow/ai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Feb 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Apr 9, 2026 8:46pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Apr 9, 2026 8:46pm
example-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-astro-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-express-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-fastify-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-hono-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-nitro-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-nuxt-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workbench-vite-workflow Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Apr 9, 2026 8:46pm
workflow-nest Ready Ready Preview, Comment Apr 9, 2026 8:46pm
workflow-swc-playground Ready Ready Preview, Comment Apr 9, 2026 8:46pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 5, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 912 0 67 979
✅ 💻 Local Development 886 0 182 1068
✅ 📦 Local Production 886 0 182 1068
✅ 🐘 Local Postgres 824 0 155 979
✅ 🪟 Windows 81 0 8 89
❌ 🌍 Community Worlds 128 73 24 225
✅ 📋 Other 225 0 42 267
Total 3942 73 660 4675

❌ Failed Tests

🌍 Community Worlds (73 failed)

mongodb (7 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KNSZZ3GQTC31QBK7AQ6G3TBN
  • webhookWorkflow | wrun_01KNSZZBRCB3FK3RCY5EE4AQMH
  • fetchWorkflow | wrun_01KNT02NT8CGX2R0EJGHW1TK66
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KNT06KEG5P2YMXCVTRAX12BC
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • health check (CLI) - workflow health command reports healthy endpoints
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KNT0CH1TNKS2G1J1A7Q8CE5N

redis (7 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KNSZZ3GQTC31QBK7AQ6G3TBN
  • webhookWorkflow | wrun_01KNSZZBRCB3FK3RCY5EE4AQMH
  • fetchWorkflow | wrun_01KNT02NT8CGX2R0EJGHW1TK66
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KNT06KEG5P2YMXCVTRAX12BC
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • health check (CLI) - workflow health command reports healthy endpoints
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KNT0CH1TNKS2G1J1A7Q8CE5N

turso (59 failed):

  • addTenWorkflow | wrun_01KNSZY01N3G4E1724BA16M502
  • addTenWorkflow | wrun_01KNSZY01N3G4E1724BA16M502
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KNSZZ4YP8PWJ28ZNK1QSVGXE
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KNSZY7B7F37W3GZB4W0JAW1X
  • promiseRaceWorkflow | wrun_01KNSZYBB06947BG1Z720NVNBG
  • promiseAnyWorkflow | wrun_01KNSZYD2B6NVTPQA0XZ3R8S1Y
  • importedStepOnlyWorkflow | wrun_01KNSZZFBY9BNT4XGE5VBC2CBP
  • hookWorkflow | wrun_01KNSZYRK3JTV0MXP6WW7M26H1
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KNSZZ3GQTC31QBK7AQ6G3TBN
  • webhookWorkflow | wrun_01KNSZZBRCB3FK3RCY5EE4AQMH
  • sleepingWorkflow | wrun_01KNSZZHN5H5GX82ZNV1JT5D72
  • parallelSleepWorkflow | wrun_01KNSZZXF36NGTTKQGC87ZFS5S
  • nullByteWorkflow | wrun_01KNT001X6FC6MEJKC6TWMH2AH
  • workflowAndStepMetadataWorkflow | wrun_01KNT003MYT00J0G0K33EMJQBW
  • fetchWorkflow | wrun_01KNT02NT8CGX2R0EJGHW1TK66
  • promiseRaceStressTestWorkflow | wrun_01KNT02S0637E0B57PS9KDVD2G
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KNT060X0KN5XC0X2HJ3Y4GJ8
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KNT06KEG5P2YMXCVTRAX12BC
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KNT076ZAPBVWGSH8HCNBKTNF
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KNT07TBZBGKE7CA2YWMWR547
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KNT081SPWJ3ANYRJB57527DY
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KNT086VCFSRG6VVY9HSTXPD9
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KNT0890QKHXMNAZWS76QHVKT
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • health check (CLI) - workflow health command reports healthy endpoints
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KNT08PYM5ZN3Y0PFJMP53ZWG
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KNT08W82BQ7FJXJ24H4EYQBV
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KNT091Z16A1071MBREW328M0
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KNT097NF5D8V6EDV0806YGNK
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KNT09DB3V2D3E7QZEPW5YAVQ
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KNT09KM3WVYTGS44Y8Q38N7A
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KNT09SC94H426MEZBBJ4YZPZ
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KNT0A5BP6TX7BYXAQNXDA6CB
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KNT0ADCJMPJA0EDV2E0063F5
  • cancelRun - cancelling a running workflow | wrun_01KNT0APN0XY75XCY4CFJAQK80
  • cancelRun via CLI - cancelling a running workflow | wrun_01KNT0AZJ8F7EA0V70N3WK3SRW
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KNT0BAFBRAB986K73NSGCSDY
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KNT0BWPH2SDMGWBRSAAMCCE8
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KNT0C77K805VCXGQQ711HKA1
  • importMetaUrlWorkflow - import.meta.url is available in step bundles | wrun_01KNT0CDJ25R26Q9F9CCRHGGKZ
  • metadataFromHelperWorkflow - getWorkflowMetadata/getStepMetadata work from module-level helper (#1577) | wrun_01KNT0CF9KC2ZX03QQDVQF4D36
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KNT0CH1TNKS2G1J1A7Q8CE5N
  • getterStepWorkflow - getter functions with "use step" directive | wrun_01KNT0CN1CSR93J480PKS4DBJQ

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 82 0 7
✅ example 82 0 7
✅ express 82 0 7
✅ fastify 82 0 7
✅ hono 82 0 7
✅ nextjs-turbopack 87 0 2
✅ nextjs-webpack 87 0 2
✅ nitro 82 0 7
✅ nuxt 82 0 7
✅ sveltekit 82 0 7
✅ vite 82 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 75 0 14
✅ express-stable 75 0 14
✅ fastify-stable 75 0 14
✅ hono-stable 75 0 14
✅ nextjs-turbopack-canary 62 0 27
✅ nextjs-turbopack-stable 81 0 8
✅ nextjs-webpack-canary 62 0 27
✅ nextjs-webpack-stable 81 0 8
✅ nitro-stable 75 0 14
✅ nuxt-stable 75 0 14
✅ sveltekit-stable 75 0 14
✅ vite-stable 75 0 14
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 75 0 14
✅ express-stable 75 0 14
✅ fastify-stable 75 0 14
✅ hono-stable 75 0 14
✅ nextjs-turbopack-canary 62 0 27
✅ nextjs-turbopack-stable 81 0 8
✅ nextjs-webpack-canary 62 0 27
✅ nextjs-webpack-stable 81 0 8
✅ nitro-stable 75 0 14
✅ nuxt-stable 75 0 14
✅ sveltekit-stable 75 0 14
✅ vite-stable 75 0 14
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 75 0 14
✅ express-stable 75 0 14
✅ fastify-stable 75 0 14
✅ hono-stable 75 0 14
✅ nextjs-turbopack-stable 81 0 8
✅ nextjs-webpack-canary 62 0 27
✅ nextjs-webpack-stable 81 0 8
✅ nitro-stable 75 0 14
✅ nuxt-stable 75 0 14
✅ sveltekit-stable 75 0 14
✅ vite-stable 75 0 14
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 81 0 8
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 5 0 0
❌ mongodb 55 7 8
✅ redis-dev 5 0 0
❌ redis 55 7 8
✅ turso-dev 5 0 0
❌ turso 3 59 8
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 75 0 14
✅ e2e-local-postgres-nest-stable 75 0 14
✅ e2e-local-prod-nest-stable 75 0 14

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 5, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.042s (+11.9% 🔺) 1.005s (~) 0.963s 10 1.00x
💻 Local Express 0.044s (+2.3%) 1.005s (~) 0.962s 10 1.06x
🐘 Postgres Nitro 0.061s (-2.5%) 1.009s (~) 0.948s 10 1.48x
🐘 Postgres Express 0.062s (-6.2% 🟢) 1.012s (~) 0.949s 10 1.50x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.195s (-31.0% 🟢) 2.125s (-14.4% 🟢) 1.930s 10 1.00x
▲ Vercel Express 0.221s (-8.7% 🟢) 2.239s (+5.4% 🔺) 2.018s 10 1.13x

🔍 Observability: Nitro | Express

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.125s (~) 2.006s (~) 0.881s 10 1.00x
💻 Local Nitro 1.127s (+2.2%) 2.007s (~) 0.880s 10 1.00x
🐘 Postgres Express 1.144s (~) 2.010s (~) 0.866s 10 1.02x
🐘 Postgres Nitro 1.149s (~) 2.011s (~) 0.862s 10 1.02x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.866s (~) 3.744s (-2.6%) 1.878s 10 1.00x
▲ Vercel Nitro 1.999s (-4.6%) 3.667s (-4.5%) 1.668s 10 1.07x

🔍 Observability: Express | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 10.908s (~) 11.024s (~) 0.116s 3 1.00x
💻 Local Nitro 10.909s (+2.2%) 11.023s (~) 0.114s 3 1.00x
🐘 Postgres Nitro 10.915s (+0.6%) 11.023s (~) 0.108s 3 1.00x
💻 Local Express 10.944s (~) 11.024s (~) 0.080s 3 1.00x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 17.142s (+1.9%) 19.445s (+2.4%) 2.303s 2 1.00x
▲ Vercel Express 17.157s (+2.5%) 20.249s (+8.1% 🔺) 3.091s 2 1.00x

🔍 Observability: Nitro | Express

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 14.623s (+0.9%) 15.025s (~) 0.402s 4 1.00x
🐘 Postgres Express 14.663s (+0.7%) 15.027s (~) 0.364s 4 1.00x
💻 Local Nitro 14.973s (+5.3% 🔺) 15.029s (~) 0.056s 4 1.02x
💻 Local Express 15.040s (~) 15.783s (-1.6%) 0.743s 4 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 32.289s (+7.1% 🔺) 34.388s (+6.6% 🔺) 2.099s 2 1.00x
▲ Vercel Nitro 34.864s (+11.0% 🔺) 36.861s (+9.6% 🔺) 1.997s 2 1.08x

🔍 Observability: Express | Nitro

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 14.131s (~) 15.026s (+1.9%) 0.895s 6 1.00x
🐘 Postgres Nitro 14.134s (+1.9%) 14.882s (+6.2% 🔺) 0.747s 7 1.00x
💻 Local Nitro 16.557s (+11.0% 🔺) 17.030s (+13.3% 🔺) 0.473s 6 1.17x
💻 Local Express 16.767s (-1.0%) 17.031s (~) 0.264s 6 1.19x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 56.096s (+6.8% 🔺) 58.601s (+7.0% 🔺) 2.505s 2 1.00x
▲ Vercel Nitro 68.746s (+27.5% 🔺) 70.307s (+25.7% 🔺) 1.561s 2 1.23x

🔍 Observability: Express | Nitro

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.263s (-0.7%) 2.009s (~) 0.746s 15 1.00x
🐘 Postgres Nitro 1.276s (+0.7%) 2.010s (~) 0.734s 15 1.01x
💻 Local Nitro 1.514s (+1.3%) 2.005s (~) 0.491s 15 1.20x
💻 Local Express 1.556s (+1.8%) 2.006s (~) 0.450s 15 1.23x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.099s (-15.9% 🟢) 3.583s (-9.1% 🟢) 1.484s 9 1.00x
▲ Vercel Express 2.314s (-2.5%) 4.071s (-1.5%) 1.757s 8 1.10x

🔍 Observability: Nitro | Express

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.353s (~) 3.010s (~) 0.657s 10 1.00x
🐘 Postgres Express 2.365s (+1.5%) 3.010s (~) 0.645s 10 1.01x
💻 Local Nitro 2.839s (+4.1%) 3.008s (-6.3% 🟢) 0.169s 10 1.21x
💻 Local Express 2.964s (-6.9% 🟢) 3.453s (-13.9% 🟢) 0.489s 9 1.26x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.607s (-15.1% 🟢) 4.444s (-1.4%) 1.837s 7 1.00x
▲ Vercel Express 3.262s (+23.2% 🔺) 5.134s (+25.2% 🔺) 1.872s 7 1.25x

🔍 Observability: Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.474s (-0.8%) 4.011s (~) 0.537s 8 1.00x
🐘 Postgres Express 3.486s (~) 4.009s (~) 0.523s 8 1.00x
💻 Local Nitro 8.162s (+5.4% 🔺) 9.021s (+5.9% 🔺) 0.858s 4 2.35x
💻 Local Express 8.389s (~) 8.771s (-2.8%) 0.382s 4 2.41x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.773s (-7.7% 🟢) 4.501s (-3.6%) 1.729s 7 1.00x
▲ Vercel Express 2.975s (+0.9%) 5.078s (+7.7% 🔺) 2.102s 6 1.07x

🔍 Observability: Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.276s (-1.6%) 2.009s (~) 0.733s 15 1.00x
🐘 Postgres Nitro 1.280s (+1.7%) 2.008s (~) 0.728s 15 1.00x
💻 Local Nitro 1.531s (+1.7%) 2.005s (~) 0.474s 15 1.20x
💻 Local Express 1.531s (-2.1%) 2.006s (~) 0.475s 15 1.20x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.091s (+8.6% 🔺) 3.988s (+7.4% 🔺) 1.897s 8 1.00x
▲ Vercel Nitro 2.250s (+15.2% 🔺) 3.833s (+0.6%) 1.584s 9 1.08x

🔍 Observability: Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.339s (~) 3.010s (~) 0.671s 10 1.00x
🐘 Postgres Express 2.339s (-1.7%) 3.008s (~) 0.669s 10 1.00x
💻 Local Nitro 3.060s (-12.5% 🟢) 3.676s (-8.4% 🟢) 0.616s 9 1.31x
💻 Local Express 3.123s (-1.5%) 3.885s (~) 0.763s 8 1.34x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.188s (-16.5% 🟢) 3.693s (-16.3% 🟢) 1.505s 9 1.00x
▲ Vercel Nitro 2.490s (-4.9%) 4.032s (-5.5% 🟢) 1.542s 8 1.14x

🔍 Observability: Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.495s (+0.8%) 4.013s (~) 0.518s 8 1.00x
🐘 Postgres Express 3.496s (~) 4.011s (~) 0.515s 8 1.00x
💻 Local Express 8.548s (-16.3% 🟢) 9.024s (-18.1% 🟢) 0.476s 4 2.45x
💻 Local Nitro 8.638s (+1.6%) 9.022s (~) 0.384s 4 2.47x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.958s (+0.8%) 4.368s (+3.7%) 1.411s 7 1.00x
▲ Vercel Nitro 3.340s (+29.0% 🔺) 5.112s (+19.1% 🔺) 1.772s 6 1.13x

🔍 Observability: Express | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.833s (~) 1.007s (-1.6%) 0.174s 60 1.00x
🐘 Postgres Express 0.848s (-1.9%) 1.006s (~) 0.158s 60 1.02x
💻 Local Express 0.989s (-2.5%) 1.281s (-24.6% 🟢) 0.292s 47 1.19x
💻 Local Nitro 1.004s (+36.3% 🔺) 1.469s (+46.2% 🔺) 0.464s 41 1.21x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 9.031s (-1.8%) 10.682s (-2.7%) 1.651s 6 1.00x
▲ Vercel Nitro 9.802s (+14.4% 🔺) 12.136s (+18.9% 🔺) 2.334s 5 1.09x

🔍 Observability: Express | Nitro

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.943s (~) 2.203s (+4.9%) 0.259s 41 1.00x
🐘 Postgres Express 2.091s (-3.0%) 2.944s (-1.1%) 0.853s 31 1.08x
💻 Local Express 3.014s (-2.5%) 3.585s (-10.6% 🟢) 0.571s 26 1.55x
💻 Local Nitro 3.059s (+19.1% 🔺) 3.842s (+20.8% 🔺) 0.784s 24 1.57x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 28.157s (+0.7%) 30.272s (+1.1%) 2.115s 3 1.00x
▲ Vercel Nitro 30.081s (+2.2%) 32.060s (+1.7%) 1.979s 3 1.07x

🔍 Observability: Express | Nitro

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 4.093s (+2.6%) 4.703s (+9.5% 🔺) 0.610s 26 1.00x
🐘 Postgres Express 4.296s (~) 5.012s (~) 0.716s 24 1.05x
💻 Local Express 9.084s (-1.3%) 9.633s (-3.9%) 0.549s 13 2.22x
💻 Local Nitro 9.101s (+18.0% 🔺) 9.710s (+18.2% 🔺) 0.609s 13 2.22x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 74.484s (-5.0% 🟢) 76.752s (-4.3%) 2.268s 2 1.00x
▲ Vercel Nitro 76.713s (+7.1% 🔺) 79.419s (+6.7% 🔺) 2.706s 2 1.03x

🔍 Observability: Express | Nitro

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.293s (+4.0%) 1.007s (~) 0.714s 60 1.00x
🐘 Postgres Express 0.293s (-0.9%) 1.007s (~) 0.714s 60 1.00x
💻 Local Nitro 0.596s (-10.1% 🟢) 1.005s (~) 0.408s 60 2.04x
💻 Local Express 0.675s (+13.2% 🔺) 1.095s (+7.2% 🔺) 0.420s 55 2.31x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.698s (+11.6% 🔺) 3.571s (+15.3% 🔺) 1.874s 17 1.00x
▲ Vercel Nitro 1.846s (-32.4% 🟢) 3.606s (-18.9% 🟢) 1.761s 17 1.09x

🔍 Observability: Express | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.507s (-3.5%) 1.006s (~) 0.500s 90 1.00x
🐘 Postgres Nitro 0.509s (+4.1%) 1.007s (~) 0.498s 90 1.00x
💻 Local Nitro 2.534s (-7.8% 🟢) 3.009s (-5.5% 🟢) 0.475s 30 5.00x
💻 Local Express 2.896s (+15.3% 🔺) 3.354s (+11.5% 🔺) 0.459s 29 5.71x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.747s (~) 4.604s (+5.9% 🔺) 1.857s 20 1.00x
▲ Vercel Nitro 2.957s (+19.6% 🔺) 4.842s (+17.0% 🔺) 1.885s 19 1.08x

🔍 Observability: Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.832s (+5.9% 🔺) 1.019s (+1.2%) 0.187s 118 1.00x
🐘 Postgres Express 0.836s (-0.5%) 1.011s (-0.8%) 0.175s 119 1.00x
💻 Local Nitro 11.010s (-1.2%) 11.666s (-1.5%) 0.656s 11 13.23x
💻 Local Express 11.328s (+3.0%) 11.939s (+2.4%) 0.612s 11 13.61x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 7.295s (-5.0%) 9.232s (-1.3%) 1.937s 14 1.00x
▲ Vercel Express 7.686s (+12.3% 🔺) 9.900s (+13.9% 🔺) 2.214s 13 1.05x

🔍 Observability: Nitro | Express

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.201s (+35.9% 🔺) 1.004s (~) 0.012s (+28.6% 🔺) 1.018s (~) 0.817s 10 1.00x
💻 Local Express 0.208s (-0.6%) 1.004s (~) 0.012s (-4.1%) 1.018s (~) 0.810s 10 1.04x
🐘 Postgres Express 0.212s (-1.2%) 1.000s (+0.6%) 0.001s (-41.2% 🟢) 1.011s (~) 0.799s 10 1.06x
🐘 Postgres Nitro 0.222s (+11.5% 🔺) 0.994s (~) 0.001s (-12.5% 🟢) 1.012s (~) 0.790s 10 1.11x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.424s (-3.7%) 2.577s (-14.8% 🟢) 0.653s (+115.5% 🔺) 3.704s (-1.7%) 2.280s 10 1.00x
▲ Vercel Nitro 1.723s (+10.9% 🔺) 3.104s (+3.7%) 0.328s (-44.0% 🟢) 4.004s (~) 2.281s 10 1.21x

🔍 Observability: Express | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.670s (+10.8% 🔺) 1.003s (~) 0.004s (+4.7%) 1.023s (~) 0.353s 59 1.00x
🐘 Postgres Express 0.676s (+4.5%) 1.004s (~) 0.008s (+48.8% 🔺) 1.027s (~) 0.351s 59 1.01x
💻 Local Express 0.726s (-1.2%) 1.013s (~) 0.009s (-1.1%) 1.024s (~) 0.297s 59 1.08x
💻 Local Nitro 0.822s (+24.2% 🔺) 1.012s (~) 0.010s (-9.6% 🟢) 1.116s (+8.8% 🔺) 0.294s 54 1.23x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.784s (-10.5% 🟢) 5.652s (-3.4%) 0.611s (+177.7% 🔺) 6.719s (+3.5%) 2.935s 10 1.00x
▲ Vercel Nitro 4.117s (-6.0% 🟢) 5.743s (-4.2%) 0.246s (-16.6% 🟢) 6.438s (-5.6% 🟢) 2.321s 10 1.09x

🔍 Observability: Express | Nitro

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.986s (+4.4%) 1.245s (+8.6% 🔺) 0.000s (+8.3% 🔺) 1.259s (+8.4% 🔺) 0.273s 48 1.00x
🐘 Postgres Express 1.027s (+3.5%) 1.497s (+12.1% 🔺) 0.000s (+10.0% 🔺) 1.509s (+10.2% 🔺) 0.482s 40 1.04x
💻 Local Nitro 1.221s (-7.9% 🟢) 2.021s (~) 0.000s (-64.7% 🟢) 2.023s (~) 0.802s 30 1.24x
💻 Local Express 1.232s (-3.3%) 2.020s (~) 0.000s (+18.2% 🔺) 2.023s (~) 0.791s 30 1.25x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.678s (+1.3%) 3.933s (+5.9% 🔺) 0.001s (+700.0% 🔺) 4.382s (+5.8% 🔺) 1.704s 15 1.00x
▲ Vercel Nitro 2.902s (+14.0% 🔺) 4.297s (+19.9% 🔺) 0.000s (-42.3% 🟢) 4.705s (+14.6% 🔺) 1.803s 13 1.08x

🔍 Observability: Express | Nitro

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.823s (+2.2%) 2.220s (+5.8% 🔺) 0.000s (+222.2% 🔺) 2.233s (+5.7% 🔺) 0.410s 27 1.00x
🐘 Postgres Express 1.919s (+7.4% 🔺) 2.218s (+3.5%) 0.000s (NaN%) 2.233s (+3.8%) 0.314s 27 1.05x
💻 Local Nitro 3.500s (-11.3% 🟢) 4.034s (-11.0% 🟢) 0.000s (-74.5% 🟢) 4.036s (-11.0% 🟢) 0.536s 15 1.92x
💻 Local Express 3.590s (-2.0%) 4.101s (-1.6%) 0.001s (-18.2% 🟢) 4.104s (-1.6%) 0.514s 15 1.97x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.632s (+7.0% 🔺) 5.248s (+8.1% 🔺) 0.000s (-63.6% 🟢) 5.681s (+6.9% 🔺) 2.049s 11 1.00x
▲ Vercel Nitro 3.873s (+8.9% 🔺) 5.465s (+11.7% 🔺) 0.001s (+118.2% 🔺) 5.914s (+11.0% 🔺) 2.042s 11 1.07x

🔍 Observability: Express | Nitro

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 15/21
🐘 Postgres Nitro 14/21
▲ Vercel Express 15/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 15/21
Nitro 🐘 Postgres 15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

Comment thread packages/core/src/runtime/world.ts
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment thread packages/web-shared/src/index.ts Outdated
This reverts commit 15193e8.
Copy link
Copy Markdown
Contributor

@pranaygp pranaygp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: [core] Refactor getWorld interface to be asynchronous

This is a well-executed refactor that enables dynamic imports for custom world implementations. The async conversion is thorough across the codebase, the promise caching in getWorld/getWorldHandlers correctly prevents race conditions, and the new Function trick for hiding dynamic imports from bundlers is pragmatic. A few items below.

Comment thread packages/core/src/runtime/world.ts Outdated
}

/**
* This hides the dynamic import behind a function to prevent the bundler from
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using new Function('specifier', 'return import(specifier)') is a well-known trick to hide dynamic imports from bundlers, but it has a security implication: it uses eval-like behavior that may be flagged by CSP policies (unsafe-eval). Worth adding a comment explaining why this is necessary and that it runs server-side only (not in browser contexts where CSP would matter).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — there's already a comment above explaining why the new Function indirection is needed (added in an earlier commit per Nathan's review). Since this only runs server-side in Node.js workers, CSP isn't a concern here.

}
}

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good use of promise caching with WorldCachePromise to prevent concurrent createWorld() calls. However, there's a subtle issue: if createWorld() rejects (e.g., the custom world module fails to load), the rejected promise is cached in StubbedWorldCachePromise permanently. Subsequent calls to getWorldHandlers() will await the same rejected promise and fail forever without retrying. Consider clearing the promise cache on rejection:

if (!globalSymbols[StubbedWorldCachePromise]) {
  globalSymbols[StubbedWorldCachePromise] = createWorld().catch((err) => {
    globalSymbols[StubbedWorldCachePromise] = undefined;
    throw err;
  });
}

Same applies to getWorld() below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch — fixed in b16728c. Both getWorld() and getWorldHandlers() now clear the cached promise on rejection so subsequent calls can retry:

globalSymbols[WorldCachePromise] = createWorld().catch((err) => {
  globalSymbols[WorldCachePromise] = undefined;
  throw err;
});

*/
runId: string;

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing worldPromise instead of world is a clean approach. One consideration: every property getter (status, workflowName, createdAt, etc.) now chains .then() on the promise, meaning each access re-awaits the resolved promise. This is harmless (resolved promises resolve immediately on .then()) but creates an extra microtask per access. If performance of these getters matters in hot paths, you could cache the resolved world. But this is a very minor nit - the current approach is clean and correct.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the extra microtask from .then() on a resolved promise is negligible. These getters aren't in hot paths (they're called once per client-side getRun() access), so the simplicity win is worth it.

const DEFAULT_STEP_MAX_RETRIES = 3;

const stepHandler = getWorldHandlers().createQueueHandler(
'__wkf_step_',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactor from getWorldHandlers().createQueueHandler(...) to (worldHandlers: WorldHandlers) => worldHandlers.createQueueHandler(...) with deferred resolution at withHealthCheck(async (req) => stepHandler(await getWorldHandlers())(req)) means getWorldHandlers() is called on every request. Since getWorldHandlers now caches via the promise + resolved value, the overhead is minimal (just checking the cache), but it's worth confirming this doesn't create a new handler instance per request. Looking at the flow: stepHandler(worldHandlers) returns the result of createQueueHandler(...) - so yes, a new handler is created per request. Consider memoizing at the handler level:

let cachedHandler: ReturnType<typeof worldHandlers.createQueueHandler> | null = null;
const getHandler = async (req: Request) => {
  if (!cachedHandler) cachedHandler = stepHandler(await getWorldHandlers());
  return cachedHandler(req);
};

This avoids recreating the queue handler on every request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — the resolved handler is now cached per-process, see the reply to Nathan's comment on the same issue.

Comment thread packages/core/src/serialization.ts Outdated
if (!reader) {
const world = getWorld();
const world = await getWorld();
const stream = await world.readFromStream(name, startIndex);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving await getWorld() inside pull means the world is resolved lazily on first read. This is a nice improvement - it means stream construction doesn't block on world resolution.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// Already a file:// URL
if (specifier.startsWith('file://')) {
return specifier;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveModulePath function handles several cases well (file://, absolute, relative, package specifiers). One edge case: if require.resolve(specifier) fails (package not installed), the catch block falls through to returning the raw specifier, which will then fail at dynamicImport. The error message from dynamicImport may be confusing (e.g., "Cannot find module './some-package'"). Consider re-throwing the require.resolve error with a more helpful message, or at least logging a warning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — in practice the raw specifier gets passed to dynamicImport() which produces a reasonably clear ERR_MODULE_NOT_FOUND with the specifier in the message. Wrapping it further might actually obscure the native error. I think the current behavior is acceptable but happy to revisit if users report confusing errors.

VaguelySerious and others added 2 commits April 3, 2026 12:18
# Conflicts:
#	docs/content/docs/api-reference/workflow-api/get-world.mdx
#	packages/core/src/runtime.ts
#	packages/core/src/runtime/helpers.ts
#	packages/core/src/runtime/resume-hook.ts
#	packages/core/src/runtime/start.ts
#	packages/core/src/runtime/step-handler.ts
#	packages/core/src/runtime/world.ts
#	packages/core/src/serialization.ts
#	packages/world-postgres/HOW_IT_WORKS.md
#	workbench/astro/src/pages/api/test-health-check.ts
#	workbench/example/api/test-health-check.ts
#	workbench/express/src/index.ts
#	workbench/fastify/src/index.ts
#	workbench/hono/src/index.ts
#	workbench/nest/src/app.controller.ts
#	workbench/nextjs-turbopack/app/api/test-health-check/route.ts
#	workbench/nextjs-webpack/app/api/test-health-check/route.ts
#	workbench/nitro-v2/server/api/test-health-check.post.ts
#	workbench/nitro-v3/routes/api/test-health-check.post.ts
#	workbench/nuxt/server/api/test-health-check.post.ts
#	workbench/sveltekit/src/routes/api/test-health-check/+server.ts
#	workbench/vite/routes/api/test-health-check.post.ts
- Remove duplicate WORKFLOW_LOCAL_BASE_URL in env.ts
- Add await to createWorld() call in CLI setup (now async)
- Update step-handler tests for async getWorldHandlers pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core design is sound — the promise-deduplication caching pattern in getWorld/getWorldHandlers is correct, the new Function('specifier', 'return import(specifier)') trick to hide dynamic imports from bundlers is the right approach, and all callers in the codebase have been updated. The Run class correctly stores worldPromise and resolves it once before the poll loop. The documentation and workbench updates are thorough.

However, there are two blocking issues and some non-blocking concerns.

Blocking

  1. createQueueHandler is called on every request — In the Vercel world, createQueueHandler allocates a new QueueClient and calls client.handleCallback() per invocation. Previously this happened once at module init time and the handler was reused for the process lifetime. Now it happens on every incoming request. This is a measurable regression on the hottest path in the system (every workflow and step invocation).

  2. Changeset should mark this as a breaking changegetWorld() changing from () => World to () => Promise<World> is a breaking API change. All external consumers must add await. The changeset should include **BREAKING CHANGE** in its description per AGENTS.md policy.

Non-blocking

  • Unrelated formatting changes (double→single quotes in ai-agent-detection.ts, proxy.ts; object formatting in hooks-table.tsx, use-resource-data.test.ts) add noise to the diff. Consider splitting these out.
  • setWorld(undefined) while a getWorldHandlers promise is in-flight could cause the old promise to overwrite the cleared cache. Only relevant in tests, but worth a comment.

if (result.timeoutSeconds !== undefined) {
return { timeoutSeconds: result.timeoutSeconds };
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: handler(await getWorldHandlers()) calls worldHandlers.createQueueHandler(prefix, callback) on every incoming request. In the Vercel world, createQueueHandler instantiates a new QueueClient and calls client.handleCallback(...) per invocation — this is not cheap.

Previously, createQueueHandler was called once at module init time and the resulting (req: Request) => Promise<Response> was reused for the process lifetime. This is a measurable regression on the hottest path in the system.

Fix: cache the resolved handler after the first call:

let cachedHandler: ((req: Request) => Promise<Response>) | undefined;
return withHealthCheck(async (req) => {
  if (!cachedHandler) {
    cachedHandler = handler(await getWorldHandlers());
  }
  return cachedHandler(req);
});

This preserves lazy initialization (world resolved on first request) while amortizing createQueueHandler across the process lifetime, matching the old behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b16728c. The resolved handler is now cached after the first call, matching the old per-process-lifetime behavior:

let cachedHandler: ((req: Request) => Promise<Response>) | undefined;
return withHealthCheck(async (req) => {
  if (!cachedHandler) {
    cachedHandler = handler(await getWorldHandlers());
  }
  return cachedHandler(req);
});

Comment thread .changeset/ninety-dancers-brush.md Outdated
"@workflow/core": patch
---

Make `getWorld` asynchronous so it can use dynamic imports
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: getWorld() changing from () => World to () => Promise<World> is a breaking API change — all external consumers must now await getWorld(). Per AGENTS.md: "Ensure that any breaking changes are marked as BREAKING CHANGE."

Suggested:

**BREAKING CHANGE**: Make `getWorld` and `createWorld` asynchronous to support ESM dynamic imports for custom world modules.

(AGENTS.md also says "all changes should be marked as patch" which is already the case — so patch + BREAKING CHANGE annotation is the correct combination.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the changeset to include the BREAKING CHANGE annotation and added @workflow/cli since its setup.ts also needed updating:

**BREAKING CHANGE**: Make `getWorld` and `createWorld` asynchronous to support ESM dynamic imports for custom world modules. All callers must now `await getWorld()`.

error: errorMessage,
stack: normalizedStack,
},
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (same issue as runtime.ts): stepHandler(await getWorldHandlers())(req) calls createQueueHandler on every step invocation request. Same fix needed — cache the resolved handler.

let cachedStepHandler: ((req: Request) => Promise<Response>) | undefined;
export const stepEntrypoint: (req: Request) => Promise<Response> =
  withHealthCheck(async (req) => {
    if (!cachedStepHandler) {
      cachedStepHandler = stepHandler(await getWorldHandlers());
    }
    return cachedStepHandler(req);
  });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the same commit — same pattern applied:

let cachedStepHandler: ((req: Request) => Promise<Response>) | undefined;
export const stepEntrypoint = withHealthCheck(async (req) => {
  if (!cachedStepHandler) {
    cachedStepHandler = stepHandler(await getWorldHandlers());
  }
  return cachedStepHandler(req);
});

}

/**
* Create a new world instance based on environment variables.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Nice pattern — new Function('specifier', 'return import(specifier)') hides the dynamic import from bundlers so they don't try to statically resolve custom world modules. Worth a comment explaining why this indirection exists, since it looks suspicious at first glance.

Also, minor: resolveModulePath converts relative paths via pathToFileURL(resolve(targetWorld)). The resolve() call uses process.cwd() as the base — is that always correct? If the workflow config is in a subdirectory, relative paths would resolve against the wrong base. This is likely an existing behavior though, not introduced by this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Function comment was added in an earlier commit. Re: process.cwd() as the resolve base — yes, this matches the existing behavior from the sync require() version which used createRequire(join(process.cwd(), 'index.js')). It resolves relative to wherever the process is running, which is the project root for both Vercel functions and local dev.

- Cache resolved queue handlers to avoid per-request createQueueHandler overhead
- Clear rejected promise cache in getWorld/getWorldHandlers so failures can be retried
- Add BREAKING CHANGE annotation to changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment on lines +1 to +6
---
"@workflow/core": patch
"@workflow/cli": patch
---

**BREAKING CHANGE**: Make `getWorld` and `createWorld` asynchronous to support ESM dynamic imports for custom world modules. All callers must now `await getWorld()`.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
---
"@workflow/core": patch
"@workflow/cli": patch
---
**BREAKING CHANGE**: Make `getWorld` and `createWorld` asynchronous to support ESM dynamic imports for custom world modules. All callers must now `await getWorld()`.
---
"@workflow/core": minor
"@workflow/cli": minor
---
**BREAKING CHANGE**: Make `getWorld` and `createWorld` asynchronous to support ESM dynamic imports for custom world modules. All callers must now `await getWorld()`.

Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All blocking issues from my previous review have been addressed in b16728cd:

  1. createQueueHandler per-request regression — Fixed. Both runtime.ts and step-handler.ts now cache the resolved handler after the first call, matching the old behavior where createQueueHandler was called once at module init. The world is still resolved lazily on first request, but the QueueClient allocation is amortized across the process lifetime.

  2. Changeset missing BREAKING CHANGE — Fixed. Now reads **BREAKING CHANGE**: Make getWorld and createWorld asynchronous.... Also correctly added @workflow/cli: patch since cli/src/base.ts was updated.

  3. Bonus: Promise rejection handlinggetWorld/getWorldHandlers now clear the cached promise on rejection via .catch((err) => { cache = undefined; throw err; }). This means if createWorld() fails (e.g., ESM import error), subsequent calls retry instead of permanently caching the failure. Good improvement.

LGTM.

`new Function('specifier', 'return import(specifier)')` throws
"A dynamic import callback was not specified" in CJS contexts like
the vitest e2e test runner. Try require() first — it works for
CJS-compatible packages (e.g. @workflow/world-postgres) and in test
runners. Fall back to dynamic import() for ESM-only modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@VaguelySerious
Copy link
Copy Markdown
Member Author

Waiting with merging until new release cycle

VaguelySerious and others added 8 commits April 7, 2026 15:11
Integrate main's resilient start (runInput), replay timeout guard,
preloaded events, expanded StartOptions exports, and streamFlushIntervalMs
into the branch's async getWorld() pattern. Cache flush interval from
resolved world promise for synchronous access in scheduleFlush.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
getWorld() is now async, but the resilient start test was missing
the await, causing it to spread a Promise instead of a World instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhook handler (resumeWebhook) needs to read hook metadata to
determine the respondWith behavior. However, the webhook Lambda may
not have the deployment encryption key available, causing metadata
hydration to fail with "Encrypted stream data encountered but no
encryption key is available".

Fix by passing undefined instead of the encryption key when
serializing hook metadata in the suspension handler. Hook metadata
is small (just respondWith config) and doesn't need encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The package version was reset from 4.2.x-beta to 4.0.0 for the v5
beta release. The encryption format capability check used 4.2.0-beta.64
as the minimum version, causing getRunCapabilities("4.0.0") to report
encryption as unsupported. This made resumeHook strip the encryption
key, while the step handler still encrypted data — causing "Encrypted
stream data encountered but no encryption key is available" errors in
the webhook handler.

Fix by lowering the minVersion to 4.0.0 to cover the reset range.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…anges

The PR branches had version 4.0.0 (intermediate changeset reset) instead
of 5.0.0-beta.0 (the actual published version). This caused
getRunCapabilities("4.0.0") to report encryption as unsupported, breaking
the webhook respondWith flow on Vercel Prod deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing after 9 new commits since my last approval (b16728cd). Most are mechanical (merge commits, version sync to 5.0.0-beta.0, hook encryption change + revert, e2e test await fix). The one substantive change is 81a548b7 which reverses the module resolution strategy: require() is now tried before dynamicImport().

Status

Commit Change Assessment
81a548b7 Try require() before dynamicImport() for custom world modules Blocking — see below
4300ff04 await getWorld() in resilient start e2e test Correct
a5b0e82d + e129d929 Hook encryption change + revert Net zero — no concern
8345fcfe + 8a5acb5c + 9220639e Capabilities/version sync Mechanical — correct
dec7335d + 1c944854 Merge commits N/A

Blocking: require() before dynamicImport() can silently load wrong export

The commit message says this fixes CJS test runners where new Function-based import() is unavailable. That's a legitimate concern. However, trying require() first on the raw targetWorld specifier introduces a subtle issue for dual-package (CJS+ESM) custom world modules.

When a package has both CJS and ESM entry points (common with "exports" in package.json), require() resolves the CJS entry and import() resolves the ESM entry. These can export different things. The whole point of making getWorld async was to support ESM — but now require() will silently succeed and load the CJS entry for dual packages, even though the user may have intended the ESM entry.

Additionally, the bare require(targetWorld) call bypasses resolveModulePath() entirely — so relative paths and file:// URLs will behave differently between the require() path and the dynamicImport() path.

Suggested fix: Try dynamicImport() first (the primary path since this PR's purpose is ESM support), fall back to require() for environments where dynamic import is unavailable:

let mod: any;
try {
  const resolvedPath = resolveModulePath(targetWorld);
  mod = await dynamicImport(resolvedPath);
} catch {
  mod = require(targetWorld);
}

This preserves ESM-first semantics while still supporting CJS test runners as a fallback.

Comment thread packages/core/src/runtime/world.ts Outdated
}

const mod = require(targetWorld);
// Try require() first — works for CJS-compatible packages and in test
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: require() before dynamicImport() has two issues:

  1. Dual-package hazard: For packages with both CJS and ESM entries, require() silently loads the CJS entry. The user may have intended the ESM entry (especially since this PR's purpose is ESM support). require() succeeding doesn't mean it loaded the right export.

  2. Path handling inconsistency: require(targetWorld) is called with the raw specifier, while dynamicImport() goes through resolveModulePath() which handles relative paths, absolute paths, and file:// URLs. A relative path like ./my-world.mjs would be resolved by require() relative to the CJS resolution algorithm (the createRequire base), but by dynamicImport() relative to process.cwd() via resolveModulePath. These can differ.

Suggested: flip the order — try dynamicImport() first, fall back to require():

let mod: any;
try {
  const resolvedPath = resolveModulePath(targetWorld);
  mod = await dynamicImport(resolvedPath);
} catch {
  mod = require(targetWorld);
}

This preserves ESM-first semantics (the reason this PR exists) while still supporting CJS test runners where new Function-based import() fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — flipped the order in 3fa3b6a. dynamicImport() is now tried first (through resolveModulePath()), with require() as the fallback for CJS test runners. This preserves ESM-first semantics and avoids the dual-package hazard you described.

Addresses review feedback — try ESM-first since this PR's purpose is
ESM support, fall back to require() for CJS test runners where
new Function-based import() is unavailable. This avoids the
dual-package hazard where require() silently loads the CJS entry
for packages that ship both CJS and ESM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Make getWorld interface asynchronous

This is a well-executed refactor that makes getWorld() and createWorld() async to support ESM dynamic imports for custom world modules (fixes #812, #825). The approach is sound — the promise-based caching prevents race conditions, the rejected promise clearing enables retry, and the handler restructuring correctly defers createQueueHandler until the world is resolved.

What looks good

  • Race condition prevention: getWorld() and getWorldHandlers() store the promise immediately in globalSymbols[WorldCachePromise] before awaiting, so concurrent callers share the same in-flight promise. This is the correct pattern.

  • Rejected promise clearing: .catch((err) => { globalSymbols[WorldCachePromise] = undefined; throw err; }) ensures transient failures (e.g., network errors during module loading) don't permanently poison the cache. Subsequent calls get a fresh attempt.

  • Handler restructuring: Both workflowEntrypoint and stepEntrypoint are correctly changed from eagerly creating the queue handler at module scope (const handler = getWorldHandlers().createQueueHandler(...)) to a factory pattern (const handler = (worldHandlers: WorldHandlers) => worldHandlers.createQueueHandler(...)). The withHealthCheck wrapper resolves the world and passes the handlers. This means the async world resolution happens once per cold start, not per request.

  • Module resolution order: ESM-first with dynamicImport() falling back to require() is correct for this PR's purpose. The resolveModulePath helper correctly handles file URLs, absolute paths, relative paths, and package specifiers.

  • new Function for dynamic import: Hiding import() behind new Function('specifier', 'return import(specifier)') prevents bundlers from trying to resolve the custom world module at build time. This is a well-known pattern.

  • setWorld() clears promise caches: Both WorldCachePromise and StubbedWorldCachePromise are cleared when setWorld() is called, preventing stale promises from overriding the explicitly set world instance.

  • Breaking change annotation: The changeset correctly includes **BREAKING CHANGE** since all callers must now await getWorld().

  • Docs updated: All code samples in the API reference docs are updated to use await getWorld().

Observations (non-blocking)

  1. Cached queue handlers: After the getWorldHandlers() promise resolves, the resolved world is cached in globalSymbols[StubbedWorldCache]. On subsequent calls, the sync path returns immediately without awaiting. Similarly, getWorld() caches in globalSymbols[WorldCache]. The promise is only used for the initial resolution. This means per-request overhead is just a globalThis symbol lookup — negligible.

  2. createRequire path: Changed from join(process.cwd(), 'index.js') to pathToFileURL(process.cwd() + '/package.json').href. This is a correctness improvement — pathToFileURL handles special characters and Windows paths correctly.

  3. Large diff size (1456 additions / 1261 deletions): Most of this is re-indentation from wrapping the handler bodies inside the factory function. The actual logic changes are confined to world.ts (~70 lines of new code) and the handler wiring in runtime.ts/step-handler.ts.

VaguelySerious and others added 2 commits April 9, 2026 10:33
Resolve conflicts preserving the async getWorldHandlers interface while
incorporating main's specVersion health check parameter and
features.encryption context field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blocking issue from my last review is resolved in 3fa3b6aa:

require() before dynamicImport() → flipped to dynamicImport() first — Exact fix I suggested. dynamicImport() (ESM-first) is now tried first via resolveModulePath(), with require() as fallback for CJS test runners. This preserves ESM-first semantics (the purpose of this PR) while still supporting environments where new Function-based import() is unavailable.

The merge artifact fix (1c663f6f) is clean — removes a duplicate MAX_QUEUE_DELIVERIES import from merge conflict resolution and adds the features: { encryption: !!encryptionKey } field that was introduced on main after the branch diverged.

LGTM.

Resolve conflicts: async getWorld() with main's world.streams API (streams docs,
e2e, run.getTailIndex, serialization stream helpers).

Made-with: Cursor
- Clarify getWorld API card, get-world page, and world overview/streams
- Add getWorld() to docs-typecheck globals
- SKILL: await getWorld(), world.streams API, bump to 1.7

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants