From da210ba962c988db9d12e644c6ece1ff9b75a22d Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 14:11:25 -0800 Subject: [PATCH 01/32] feat: add getPort method for detecting pid port --- packages/core/package.json | 1 - packages/core/src/runtime.ts | 5 ++++- packages/core/src/runtime/world.ts | 5 ++++- packages/core/src/workflow.ts | 4 +++- pnpm-lock.yaml | 11 ----------- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 864f3d799b..213032871a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,7 +59,6 @@ "devalue": "^5.4.1", "ms": "2.1.3", "nanoid": "^5.1.6", - "pid-port": "^2.0.0", "seedrandom": "^3.0.5", "ulid": "^3.0.1", "zod": "catalog:" diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 66a5b5616f..5e2b229598 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -39,6 +39,7 @@ import { serializeTraceCarrier, trace, withTraceContext } from './telemetry.js'; import { getErrorName, getErrorStack } from './types.js'; import { buildWorkflowSuspensionMessage, + getPort, getWorkflowRunStreamId, } from './util.js'; import { runWorkflow } from './workflow.js'; @@ -658,6 +659,8 @@ export const stepEntrypoint = ...Attribute.StepArgumentsCount(args.length), }); + const port = getPort(); + result = await contextStorage.run( { stepMetadata: { @@ -672,7 +675,7 @@ export const stepEntrypoint = // solution only works for vercel + embedded worlds. url: process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${process.env.PORT || 3000}`, + : `http://localhost:${port}`, }, ops, }, diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 5b35d95877..8ebd358e26 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -3,6 +3,7 @@ import Path from 'node:path'; import type { World } from '@workflow/world'; import { createEmbeddedWorld } from '@workflow/world-local'; import { createVercelWorld } from '@workflow/world-vercel'; +import { getPort } from '../util.js'; const require = createRequire(Path.join(process.cwd(), 'index.js')); @@ -37,10 +38,12 @@ export const createWorld = (): World => { }); } + const port = getPort() ?? undefined; + if (targetWorld === 'embedded') { return createEmbeddedWorld({ dataDir: process.env.WORKFLOW_EMBEDDED_DATA_DIR, - port: process.env.PORT ? Number(process.env.PORT) : undefined, + port, }); } diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 6f0495ee81..b38a61f056 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -97,11 +97,13 @@ export async function runWorkflow( vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) => getWorkflowRunStreamId(workflowRun.runId, namespace); + const port = getPort(); + // TODO: there should be a getUrl method on the world interface itself. This // solution only works for vercel + embedded worlds. const url = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${process.env.PORT || 3000}`; + : `http://localhost:${port}`; // For the workflow VM, we store the context in a symbol on the `globalThis` object const ctx: WorkflowMetadata = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01614c57d6..48fd94532f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,9 +450,6 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.6 - pid-port: - specifier: ^2.0.0 - version: 2.0.0 seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -8204,10 +8201,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pid-port@2.0.0: - resolution: {integrity: sha512-EDmfRxLl6lkhPjDI+19l5pkII89xVsiCP3aGjS808f7M16DyCKSXEWthD/hjyDLn5I4gKqTVw7hSgdvdXRJDTw==} - engines: {node: '>=20'} - pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -18264,10 +18257,6 @@ snapshots: picomatch@4.0.3: {} - pid-port@2.0.0: - dependencies: - execa: 9.6.0 - pidtree@0.6.0: {} pify@4.0.1: {} From 54b47f316c83eba70053c5f33c8dbaf317453a69 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 14:11:33 -0800 Subject: [PATCH 02/32] lockfile --- pnpm-lock.yaml | 116 +++++-------------------------------------------- 1 file changed, 11 insertions(+), 105 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48fd94532f..4624169710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,36 +6,12 @@ settings: catalogs: default: - '@biomejs/biome': - specifier: ^2.2.7 - version: 2.3.3 '@types/node': specifier: 22.19.0 version: 22.19.0 '@vercel/functions': specifier: ^3.1.4 version: 3.1.4 - '@vercel/oidc': - specifier: ^3.0.3 - version: 3.0.3 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4 - ai: - specifier: 5.0.76 - version: 5.0.76 - esbuild: - specifier: ^0.25.11 - version: 0.25.11 - nitro: - specifier: npm:nitro-nightly@3.0.1-20251031-202656-883af4f9 - version: 3.0.1-20251031-202656-883af4f9 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.4 zod: specifier: 4.1.11 version: 4.1.11 @@ -440,7 +416,7 @@ importers: version: link:../world-vercel debug: specifier: ^4.4.3 - version: 4.4.3(supports-color@8.1.1) + version: 4.4.3 devalue: specifier: ^5.4.1 version: 5.4.1 @@ -9752,46 +9728,6 @@ packages: vite: ^6.0.0 || ^7.0.0 vue: ^3.5.0 - vite@7.1.11: - resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.1.12: resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14360,13 +14296,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) + vite: 7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -15370,6 +15306,10 @@ snapshots: better-sqlite3: 11.10.0 drizzle-orm: 0.31.4(@opentelemetry/api@1.9.0)(@types/react@19.1.13)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)(react@19.2.0) + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -20050,40 +19990,6 @@ snapshots: vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) vue: 3.5.22(typescript@5.9.3) - vite@7.1.11(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.52.5 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.0 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.1 - terser: 5.44.0 - tsx: 4.20.6 - yaml: 2.8.0 - - vite@7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.52.5 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.6.2 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.1 - terser: 5.44.0 - tsx: 4.20.6 - yaml: 2.8.0 - vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0): dependencies: esbuild: 0.25.11 @@ -20130,7 +20036,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -20148,7 +20054,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.11(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -20172,7 +20078,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -20190,7 +20096,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.11(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) + vite: 7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) vite-node: 3.2.4(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: From 36c5cf2664ccbe618dc80805a8da19531a06bcc7 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 16:00:35 -0800 Subject: [PATCH 03/32] update tests and fix getPort usage --- .github/workflows/tests.yml | 10 +++++++++- packages/core/src/runtime.ts | 4 +--- packages/core/src/runtime/world.ts | 4 +--- packages/core/src/workflow.ts | 4 +--- workbench/sveltekit/package.json | 4 ++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2d9a620981..bbe9dd4d3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -206,7 +206,7 @@ jobs: run: cd workbench/${{ matrix.app.name }} && pnpm dev & echo "starting tests in 10 seconds" && sleep 10 && pnpm vitest run packages/core/e2e/e2e.test.ts env: APP_NAME: ${{ matrix.app.name }} - DEPLOYMENT_URL: "http://localhost:3000" + DEPLOYMENT_URL: "http://localhost:5173" e2e-local-prod: name: E2E Local Prod Tests (${{ matrix.app.name }} - ${{ matrix.app.canary && 'canary' || 'stable' }}) @@ -254,10 +254,18 @@ jobs: APP_NAME: ${{ matrix.app.name }} - name: Run E2E Tests + if: matrix.app.name != 'sveltekit' run: cd workbench/${{ matrix.app.name }} && pnpm start & echo "starting tests in 10 seconds" && sleep 10 && pnpm run test:e2e env: APP_NAME: ${{ matrix.app.name }} DEPLOYMENT_URL: "http://localhost:3000" + + - name: Run E2E Tests (Different Port) + if: matrix.app.name == 'sveltekit' + run: cd workbench/${{ matrix.app.name }} && pnpm start & echo "starting tests in 10 seconds" && sleep 10 && pnpm run test:e2e + env: + APP_NAME: ${{ matrix.app.name }} + DEPLOYMENT_URL: "http://localhost:4173" e2e-windows: name: E2E Windows Tests diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 5e2b229598..f7dc319624 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -659,8 +659,6 @@ export const stepEntrypoint = ...Attribute.StepArgumentsCount(args.length), }); - const port = getPort(); - result = await contextStorage.run( { stepMetadata: { @@ -675,7 +673,7 @@ export const stepEntrypoint = // solution only works for vercel + embedded worlds. url: process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${port}`, + : `http://localhost:${getPort()}`, }, ops, }, diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 8ebd358e26..f367dd8cdd 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -38,12 +38,10 @@ export const createWorld = (): World => { }); } - const port = getPort() ?? undefined; - if (targetWorld === 'embedded') { return createEmbeddedWorld({ dataDir: process.env.WORKFLOW_EMBEDDED_DATA_DIR, - port, + port: getPort(), }); } diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index b38a61f056..e8eafe08b0 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -97,13 +97,11 @@ export async function runWorkflow( vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) => getWorkflowRunStreamId(workflowRun.runId, namespace); - const port = getPort(); - // TODO: there should be a getUrl method on the world interface itself. This // solution only works for vercel + embedded worlds. const url = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${port}`; + : `http://localhost:${getPort()}`; // For the workflow VM, we store the context in a symbol on the `globalThis` object const ctx: WorkflowMetadata = { diff --git a/workbench/sveltekit/package.json b/workbench/sveltekit/package.json index 7669e75794..9a22456a6b 100644 --- a/workbench/sveltekit/package.json +++ b/workbench/sveltekit/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite dev --port 3000", + "dev": "vite dev", "build": "vite build", - "start": "vite preview --port 3000", + "start": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" From 20f009b99e6400ec73202769a1f151ed5ae1b74c Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 16:12:24 -0800 Subject: [PATCH 04/32] changeset --- .changeset/smooth-rats-attack.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smooth-rats-attack.md diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md new file mode 100644 index 0000000000..6a0f4244c9 --- /dev/null +++ b/.changeset/smooth-rats-attack.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Add automatic port discovery From b4a7604ce8e40a11329b09d62cc9a5cc88209e08 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 16:16:41 -0800 Subject: [PATCH 05/32] docs: update sveltekit getting started --- docs/content/docs/getting-started/sveltekit.mdx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/content/docs/getting-started/sveltekit.mdx b/docs/content/docs/getting-started/sveltekit.mdx index 1c15df29df..a4b4aa197a 100644 --- a/docs/content/docs/getting-started/sveltekit.mdx +++ b/docs/content/docs/getting-started/sveltekit.mdx @@ -59,20 +59,6 @@ export default defineConfig({ }); ``` -### Update `package.json` - -Update your `package.json` to include port `3000` for the development server: - -```json title="package.json" lineNumbers -{ - // ... - "scripts": { - "dev": "vite dev --port 3000" - // ... - }, -} -``` - @@ -229,7 +215,7 @@ npm run dev Once your development server is running, you can trigger your workflow by running this command in the terminal: ```bash -curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup +curl -X POST --json '{"email":"hello@example.com"}' http://localhost:5173/api/signup ``` Check the SvelteKit development server logs to see your workflow execute as well as the steps that are being processed. From 9d8d329521c8309aa04ba3bdbf0f16f98dcbdcf1 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 19:37:16 -0800 Subject: [PATCH 06/32] fix: use pid-port --- packages/core/package.json | 1 + packages/core/src/runtime.ts | 2 +- packages/core/src/runtime/world.ts | 1 - packages/core/src/util.test.ts | 7 ++ packages/core/src/util.ts | 114 +++++++++++++++++++++++++++++ packages/core/src/workflow.ts | 2 +- packages/world-local/package.json | 1 + packages/world-local/src/queue.ts | 17 +++++ pnpm-lock.yaml | 54 ++++++++++++-- 9 files changed, 190 insertions(+), 9 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 213032871a..864f3d799b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,7 @@ "devalue": "^5.4.1", "ms": "2.1.3", "nanoid": "^5.1.6", + "pid-port": "^2.0.0", "seedrandom": "^3.0.5", "ulid": "^3.0.1", "zod": "catalog:" diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index f7dc319624..7fc54a665d 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -673,7 +673,7 @@ export const stepEntrypoint = // solution only works for vercel + embedded worlds. url: process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${getPort()}`, + : `http://localhost:${await getPort()}`, }, ops, }, diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index f367dd8cdd..0117be9fe8 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -41,7 +41,6 @@ export const createWorld = (): World => { if (targetWorld === 'embedded') { return createEmbeddedWorld({ dataDir: process.env.WORKFLOW_EMBEDDED_DATA_DIR, - port: getPort(), }); } diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 8163fec83d..46d1aa3d12 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,3 +1,10 @@ +<<<<<<< HEAD +======= + +import http from 'node:http'; + +>>>>>>> 456d8ef6 (fix: use pid-port) + import { describe, expect, it } from 'vitest'; import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util'; diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 5c13219493..26ae9436ab 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,3 +1,60 @@ +<<<<<<< HEAD +======= +/* + * This file contains methods from pid-port by Sindre Sorhus (MIT License) + * adapted for synchronous calls + * https://github.com/sindresorhus/pid-port + */ + +import { execSync } from 'node:child_process'; +import process from 'node:process'; +import type { StringValue } from 'ms'; +import ms from 'ms'; +import { pidToPorts } from 'pid-port'; + +export interface PromiseWithResolvers { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: any) => void; +} + +/** + * Polyfill for `Promise.withResolvers()`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers + */ +export function withResolvers(): PromiseWithResolvers { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { promise, resolve, reject }; +} + +/** + * Creates a lazily-evaluated, memoized version of the provided function. + * + * The returned object exposes a `value` getter that calls `fn` only once, + * caches its result, and returns the cached value on subsequent accesses. + * + * @typeParam T - The return type of the provided function. + * @param fn - The function to be called once and whose result will be cached. + * @returns An object with a `value` property that returns the memoized result of `fn`. + */ +export function once(fn: () => T) { + const result = { + get value() { + const value = fn(); + Object.defineProperty(result, 'value', { value }); + return value; + }, + }; + return result; +} + +>>>>>>> 456d8ef6 (fix: use pid-port) /** * Builds a workflow suspension log message based on the counts of steps, hooks, and waits. * @param runId - The workflow run ID @@ -62,3 +119,60 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) { ); return `${streamId}_${encodedNamespace}`; } +<<<<<<< HEAD +======= + +/** + * Parses a duration parameter (string, number, or Date) and returns a Date object + * representing when the duration should elapse. + * + * - For strings: Parses duration strings like "1s", "5m", "1h", etc. using the `ms` library + * - For numbers: Treats as milliseconds from now + * - For Date objects: Returns the date directly (handles both Date instances and date-like objects from deserialization) + * + * @param param - The duration parameter (StringValue, Date, or number of milliseconds) + * @returns A Date object representing when the duration should elapse + * @throws {Error} If the parameter is invalid or cannot be parsed + */ +export function parseDurationToDate(param: StringValue | Date | number): Date { + if (typeof param === 'string') { + const durationMs = ms(param); + if (typeof durationMs !== 'number' || durationMs < 0) { + throw new Error( + `Invalid duration: "${param}". Expected a valid duration string like "1s", "1m", "1h", etc.` + ); + } + return new Date(Date.now() + durationMs); + } else if (typeof param === 'number') { + if (param < 0 || !Number.isFinite(param)) { + throw new Error( + `Invalid duration: ${param}. Expected a non-negative finite number of milliseconds.` + ); + } + return new Date(Date.now() + param); + } else if ( + param instanceof Date || + (param && + typeof param === 'object' && + typeof (param as any).getTime === 'function') + ) { + // Handle both Date instances and date-like objects (from deserialization) + return param instanceof Date ? param : new Date((param as any).getTime()); + } else { + throw new Error( + `Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.` + ); + } +} + +export async function getPort(): Promise { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; +} +>>>>>>> 456d8ef6 (fix: use pid-port) diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index e8eafe08b0..ca552eff7b 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -101,7 +101,7 @@ export async function runWorkflow( // solution only works for vercel + embedded worlds. const url = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${getPort()}`; + : `http://localhost:${await getPort()}`; // For the workflow VM, we store the context in a symbol on the `globalThis` object const ctx: WorkflowMetadata = { diff --git a/packages/world-local/package.json b/packages/world-local/package.json index f6526922e5..7bef533bec 100644 --- a/packages/world-local/package.json +++ b/packages/world-local/package.json @@ -32,6 +32,7 @@ "dependencies": { "@vercel/queue": "0.0.0-alpha.23", "@workflow/world": "workspace:*", + "pid-port": "^2.0.0", "ulid": "^3.0.1", "undici": "^6.19.0", "zod": "catalog:" diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 5ea1fd34b2..7d39cb3a75 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,6 +1,7 @@ import { setTimeout } from 'node:timers/promises'; import { JsonTransport } from '@vercel/queue'; import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; +import { pidToPorts } from 'pid-port'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; import z from 'zod'; @@ -57,6 +58,7 @@ export function createQueue(port?: number): Queue { (async () => { let defaultRetriesLeft = 3; + const port = await getPort(); for (let attempt = 0; defaultRetriesLeft > 0; attempt++) { defaultRetriesLeft--; @@ -170,3 +172,18 @@ export function createQueue(port?: number): Queue { return { queue, createQueueHandler, getDeploymentId }; } + +/** + * Gets the port number that the process is listening on. + * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. + */ +async function getPort(): Promise { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4624169710..f8173108f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,36 @@ settings: catalogs: default: + '@biomejs/biome': + specifier: ^2.2.7 + version: 2.3.3 '@types/node': specifier: 22.19.0 version: 22.19.0 '@vercel/functions': specifier: ^3.1.4 version: 3.1.4 + '@vercel/oidc': + specifier: ^3.0.3 + version: 3.0.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4 + ai: + specifier: 5.0.76 + version: 5.0.76 + esbuild: + specifier: ^0.25.11 + version: 0.25.11 + nitro: + specifier: npm:nitro-nightly@3.0.1-20251031-202656-883af4f9 + version: 3.0.1-20251031-202656-883af4f9 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4 zod: specifier: 4.1.11 version: 4.1.11 @@ -416,7 +440,7 @@ importers: version: link:../world-vercel debug: specifier: ^4.4.3 - version: 4.4.3 + version: 4.4.3(supports-color@8.1.1) devalue: specifier: ^5.4.1 version: 5.4.1 @@ -426,6 +450,9 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.6 + pid-port: + specifier: ^2.0.0 + version: 2.0.0 seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -830,6 +857,9 @@ importers: '@workflow/world': specifier: workspace:* version: link:../world + pid-port: + specifier: ^2.0.0 + version: 2.0.0 ulid: specifier: ^3.0.1 version: 3.0.1 @@ -8177,6 +8207,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pid-port@2.0.0: + resolution: {integrity: sha512-EDmfRxLl6lkhPjDI+19l5pkII89xVsiCP3aGjS808f7M16DyCKSXEWthD/hjyDLn5I4gKqTVw7hSgdvdXRJDTw==} + engines: {node: '>=20'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -14296,6 +14330,14 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 @@ -15306,10 +15348,6 @@ snapshots: better-sqlite3: 11.10.0 drizzle-orm: 0.31.4(@opentelemetry/api@1.9.0)(@types/react@19.1.13)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)(react@19.2.0) - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -18197,6 +18235,10 @@ snapshots: picomatch@4.0.3: {} + pid-port@2.0.0: + dependencies: + execa: 9.6.0 + pidtree@0.6.0: {} pify@4.0.1: {} @@ -20036,7 +20078,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From 54e40190309cec5008adadff308d1fa720ecd991 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 19:40:01 -0800 Subject: [PATCH 07/32] fix: not using config port env --- packages/world-local/src/queue.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 7d39cb3a75..e5a9567d7c 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -58,12 +58,12 @@ export function createQueue(port?: number): Queue { (async () => { let defaultRetriesLeft = 3; - const port = await getPort(); + const portToUse = port ?? (await getPort()); for (let attempt = 0; defaultRetriesLeft > 0; attempt++) { defaultRetriesLeft--; const response = await fetch( - `http://localhost:${port}/.well-known/workflow/v1/${pathname}`, + `http://localhost:${portToUse}/.well-known/workflow/v1/${pathname}`, { method: 'POST', duplex: 'half', From cab43ef4eb9405ede5731a60fa33a777cf2af675 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 19:43:57 -0800 Subject: [PATCH 08/32] fix: remove unused getPort from world core --- packages/core/src/runtime/world.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 0117be9fe8..664944ee34 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -3,7 +3,6 @@ import Path from 'node:path'; import type { World } from '@workflow/world'; import { createEmbeddedWorld } from '@workflow/world-local'; import { createVercelWorld } from '@workflow/world-vercel'; -import { getPort } from '../util.js'; const require = createRequire(Path.join(process.cwd(), 'index.js')); From a914cd2b1888fb1fdb3f45f6cf16b2ce099475c1 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 19:45:22 -0800 Subject: [PATCH 09/32] remove unused stuff --- packages/core/src/util.ts | 116 +------------------------------ packages/utils/src/index.test.ts | 92 +++++++++++++++++++++++- packages/utils/src/index.ts | 12 ++++ 3 files changed, 104 insertions(+), 116 deletions(-) diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 26ae9436ab..2f470ce204 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,60 +1,3 @@ -<<<<<<< HEAD -======= -/* - * This file contains methods from pid-port by Sindre Sorhus (MIT License) - * adapted for synchronous calls - * https://github.com/sindresorhus/pid-port - */ - -import { execSync } from 'node:child_process'; -import process from 'node:process'; -import type { StringValue } from 'ms'; -import ms from 'ms'; -import { pidToPorts } from 'pid-port'; - -export interface PromiseWithResolvers { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: any) => void; -} - -/** - * Polyfill for `Promise.withResolvers()`. - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers - */ -export function withResolvers(): PromiseWithResolvers { - let resolve!: (value: T) => void; - let reject!: (reason?: any) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - return { promise, resolve, reject }; -} - -/** - * Creates a lazily-evaluated, memoized version of the provided function. - * - * The returned object exposes a `value` getter that calls `fn` only once, - * caches its result, and returns the cached value on subsequent accesses. - * - * @typeParam T - The return type of the provided function. - * @param fn - The function to be called once and whose result will be cached. - * @returns An object with a `value` property that returns the memoized result of `fn`. - */ -export function once(fn: () => T) { - const result = { - get value() { - const value = fn(); - Object.defineProperty(result, 'value', { value }); - return value; - }, - }; - return result; -} - ->>>>>>> 456d8ef6 (fix: use pid-port) /** * Builds a workflow suspension log message based on the counts of steps, hooks, and waits. * @param runId - The workflow run ID @@ -118,61 +61,4 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) { 'base64url' ); return `${streamId}_${encodedNamespace}`; -} -<<<<<<< HEAD -======= - -/** - * Parses a duration parameter (string, number, or Date) and returns a Date object - * representing when the duration should elapse. - * - * - For strings: Parses duration strings like "1s", "5m", "1h", etc. using the `ms` library - * - For numbers: Treats as milliseconds from now - * - For Date objects: Returns the date directly (handles both Date instances and date-like objects from deserialization) - * - * @param param - The duration parameter (StringValue, Date, or number of milliseconds) - * @returns A Date object representing when the duration should elapse - * @throws {Error} If the parameter is invalid or cannot be parsed - */ -export function parseDurationToDate(param: StringValue | Date | number): Date { - if (typeof param === 'string') { - const durationMs = ms(param); - if (typeof durationMs !== 'number' || durationMs < 0) { - throw new Error( - `Invalid duration: "${param}". Expected a valid duration string like "1s", "1m", "1h", etc.` - ); - } - return new Date(Date.now() + durationMs); - } else if (typeof param === 'number') { - if (param < 0 || !Number.isFinite(param)) { - throw new Error( - `Invalid duration: ${param}. Expected a non-negative finite number of milliseconds.` - ); - } - return new Date(Date.now() + param); - } else if ( - param instanceof Date || - (param && - typeof param === 'object' && - typeof (param as any).getTime === 'function') - ) { - // Handle both Date instances and date-like objects (from deserialization) - return param instanceof Date ? param : new Date((param as any).getTime()); - } else { - throw new Error( - `Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.` - ); - } -} - -export async function getPort(): Promise { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { - return undefined; - } - - const smallest = Math.min(...ports); - return smallest; -} ->>>>>>> 456d8ef6 (fix: use pid-port) +} \ No newline at end of file diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/index.test.ts index 60fb88c179..465fa9fd01 100644 --- a/packages/utils/src/index.test.ts +++ b/packages/utils/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { once, parseDurationToDate, withResolvers } from './index'; +import { getPort, once, parseDurationToDate, withResolvers } from './index'; describe('parseDurationToDate', () => { it('should parse duration strings correctly', () => { @@ -73,3 +73,93 @@ describe('once', () => { expect(first).toBe(second); }); }); + +describe('getPort', () => { + it('should return undefined or a positive number', async () => { + const port = await getPort(); + expect(port === undefined || typeof port === 'number').toBe(true); + if (port !== undefined) { + expect(port).toBeGreaterThan(0); + } + }); + + it('should return a port number when a server is listening', async () => { + const server = http.createServer(); + + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + + // Give system time to register the port + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const address = server.address(); + + // Port detection may not work immediately in all environments (CI, Docker, etc.) + // so we just verify the function returns a valid result + if (port !== undefined) { + expect(typeof port).toBe('number'); + expect(port).toBeGreaterThan(0); + + // If we have the address, optionally verify it matches + if (address && typeof address === 'object') { + // In most cases it should match, but not required for test to pass + expect([port, undefined]).toContain(port); + } + } + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('should return the smallest port when multiple servers are listening', async () => { + const server1 = http.createServer(); + const server2 = http.createServer(); + + await new Promise((resolve) => { + server1.listen(0, () => resolve()); + }); + + await new Promise((resolve) => { + server2.listen(0, () => resolve()); + }); + + // Give system time to register the ports + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const addr1 = server1.address(); + const addr2 = server2.address(); + + // Port detection may not work in all environments + if ( + port !== undefined && + addr1 && + typeof addr1 === 'object' && + addr2 && + typeof addr2 === 'object' + ) { + // Should return the smallest port + expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); + expect(port).toBeGreaterThan(0); + } else { + // If port detection doesn't work in this environment, just pass + expect(port === undefined || typeof port === 'number').toBe(true); + } + } finally { + await Promise.all([ + new Promise((resolve, reject) => { + server1.close((err) => (err ? reject(err) : resolve())); + }), + new Promise((resolve, reject) => { + server2.close((err) => (err ? reject(err) : resolve())); + }), + ]); + } + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6e657c9060..828369379c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ import type { StringValue } from 'ms'; import ms from 'ms'; +import pidToPorts from 'pid-port'; export interface PromiseWithResolvers { promise: Promise; @@ -85,3 +86,14 @@ export function parseDurationToDate(param: StringValue | Date | number): Date { ); } } + +export async function getPort(): Promise { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; +} \ No newline at end of file From 8eca29200a6bdc6d6020e61a7da70b109a95bf2d Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 19:57:27 -0800 Subject: [PATCH 10/32] fix: world local config returning port 3000 as fallback --- packages/world-local/src/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index b3b2815441..1e0768c1df 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -11,8 +11,7 @@ const getPortFromEnv = () => { if (port) { return Number(port); } - // - return 3000; + return undefined; }; export const config = once(() => { From c39e1448379037c317ade87c6fdbf73a23f8a8bc Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Wed, 5 Nov 2025 20:05:40 -0800 Subject: [PATCH 11/32] changeset --- .changeset/smooth-rats-attack.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md index 6a0f4244c9..ee702f4b9b 100644 --- a/.changeset/smooth-rats-attack.md +++ b/.changeset/smooth-rats-attack.md @@ -1,5 +1,6 @@ --- "@workflow/core": patch +"@workflow/world-local": patch --- Add automatic port discovery From 93862a6dd221cea1e6eec097a97aecb0969282da Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 14:23:43 -0800 Subject: [PATCH 12/32] fix: rebase conflicts --- packages/core/src/util.test.ts | 7 ------- packages/core/src/workflow.ts | 2 +- packages/utils/package.json | 3 ++- packages/utils/src/index.ts | 8 ++++++-- packages/world-local/package.json | 2 +- packages/world-local/src/queue.ts | 17 +---------------- pnpm-lock.yaml | 19 +++++++------------ 7 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 46d1aa3d12..8163fec83d 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,10 +1,3 @@ -<<<<<<< HEAD -======= - -import http from 'node:http'; - ->>>>>>> 456d8ef6 (fix: use pid-port) - import { describe, expect, it } from 'vitest'; import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util'; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index ca552eff7b..a2a8559210 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -1,6 +1,6 @@ import { runInContext } from 'node:vm'; import { ERROR_SLUGS } from '@workflow/errors'; -import { withResolvers } from '@workflow/utils'; +import { getPort, withResolvers } from '@workflow/utils'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 565fef496b..ed493d0936 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,6 +36,7 @@ "vitest": "catalog:" }, "dependencies": { - "ms": "2.1.3" + "ms": "2.1.3", + "pid-port": "^2.0.0" } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 828369379c..375f7edda0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,6 @@ import type { StringValue } from 'ms'; import ms from 'ms'; -import pidToPorts from 'pid-port'; +import { pidToPorts } from 'pid-port'; export interface PromiseWithResolvers { promise: Promise; @@ -87,6 +87,10 @@ export function parseDurationToDate(param: StringValue | Date | number): Date { } } +/** + * Gets the port number that the process is listening on. + * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. + */ export async function getPort(): Promise { const pid = process.pid; const ports = await pidToPorts(pid); @@ -96,4 +100,4 @@ export async function getPort(): Promise { const smallest = Math.min(...ports); return smallest; -} \ No newline at end of file +} diff --git a/packages/world-local/package.json b/packages/world-local/package.json index 7bef533bec..e3d82230d1 100644 --- a/packages/world-local/package.json +++ b/packages/world-local/package.json @@ -32,7 +32,7 @@ "dependencies": { "@vercel/queue": "0.0.0-alpha.23", "@workflow/world": "workspace:*", - "pid-port": "^2.0.0", + "@workflow/utils": "workspace:*", "ulid": "^3.0.1", "undici": "^6.19.0", "zod": "catalog:" diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index e5a9567d7c..07e0c40398 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises'; import { JsonTransport } from '@vercel/queue'; +import { getPort } from '@workflow/utils'; import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; -import { pidToPorts } from 'pid-port'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; import z from 'zod'; @@ -172,18 +172,3 @@ export function createQueue(port?: number): Queue { return { queue, createQueueHandler, getDeploymentId }; } - -/** - * Gets the port number that the process is listening on. - * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. - */ -async function getPort(): Promise { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { - return undefined; - } - - const smallest = Math.min(...ports); - return smallest; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8173108f5..422f845e8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -640,6 +640,9 @@ importers: ms: specifier: 2.1.3 version: 2.1.3 + pid-port: + specifier: ^2.0.0 + version: 2.0.0 devDependencies: '@types/ms': specifier: ^2.1.0 @@ -854,12 +857,12 @@ importers: '@vercel/queue': specifier: 0.0.0-alpha.23 version: 0.0.0-alpha.23 + '@workflow/utils': + specifier: workspace:* + version: link:../utils '@workflow/world': specifier: workspace:* version: link:../world - pid-port: - specifier: ^2.0.0 - version: 2.0.0 ulid: specifier: ^3.0.1 version: 3.0.1 @@ -14330,14 +14333,6 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.19 - optionalDependencies: - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 @@ -20078,7 +20073,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From b706b52d96542f4bc8d1e08993db78bd28ececaf Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 14:24:25 -0800 Subject: [PATCH 13/32] fix: util test missing http import --- packages/utils/src/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/index.test.ts index 465fa9fd01..6608efe123 100644 --- a/packages/utils/src/index.test.ts +++ b/packages/utils/src/index.test.ts @@ -1,3 +1,4 @@ +import http from 'node:http'; import { describe, expect, it } from 'vitest'; import { getPort, once, parseDurationToDate, withResolvers } from './index'; From 6c2e7a2e8d06935570480ff04d5f26283a70e201 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 14:25:36 -0800 Subject: [PATCH 14/32] changeset --- .changeset/smooth-rats-attack.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md index ee702f4b9b..091cecc365 100644 --- a/.changeset/smooth-rats-attack.md +++ b/.changeset/smooth-rats-attack.md @@ -1,5 +1,6 @@ --- "@workflow/core": patch +"@workflow/utils": patch "@workflow/world-local": patch --- From 757fa0cbef675215a074b7a24585cca5397e6bd5 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 14:37:59 -0800 Subject: [PATCH 15/32] fix: wrong import for getPort in core runtime --- packages/core/src/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 7fc54a665d..4f94e79fa8 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -8,6 +8,7 @@ import { WorkflowRunNotCompletedError, WorkflowRuntimeError, } from '@workflow/errors'; +import { getPort } from '@workflow/utils'; import type { Event, WorkflowRun, @@ -39,7 +40,6 @@ import { serializeTraceCarrier, trace, withTraceContext } from './telemetry.js'; import { getErrorName, getErrorStack } from './types.js'; import { buildWorkflowSuspensionMessage, - getPort, getWorkflowRunStreamId, } from './util.js'; import { runWorkflow } from './workflow.js'; From 62e11237d92165238ad01bdf4f65f717c473c4f6 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 15:29:06 -0800 Subject: [PATCH 16/32] fix: getPort in @workflow/utils being imported into workflow runtime --- .changeset/smooth-rats-attack.md | 1 - packages/core/src/runtime.ts | 2 +- packages/core/src/util.test.ts | 96 ++++++++++++++++++++++++++++++- packages/core/src/util.ts | 20 ++++++- packages/core/src/workflow.ts | 4 +- packages/utils/package.json | 3 +- packages/utils/src/index.test.ts | 93 +----------------------------- packages/utils/src/index.ts | 16 ------ packages/world-local/package.json | 3 +- packages/world-local/src/queue.ts | 18 +++++- pnpm-lock.yaml | 51 ++++++---------- 11 files changed, 156 insertions(+), 151 deletions(-) diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md index 091cecc365..ee702f4b9b 100644 --- a/.changeset/smooth-rats-attack.md +++ b/.changeset/smooth-rats-attack.md @@ -1,6 +1,5 @@ --- "@workflow/core": patch -"@workflow/utils": patch "@workflow/world-local": patch --- diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 4f94e79fa8..23e7a7885c 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -8,7 +8,7 @@ import { WorkflowRunNotCompletedError, WorkflowRuntimeError, } from '@workflow/errors'; -import { getPort } from '@workflow/utils'; +import { getPort } from './util.js'; import type { Event, WorkflowRun, diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 8163fec83d..35e99dbc80 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util'; +import { + buildWorkflowSuspensionMessage, + getPort, + getWorkflowRunStreamId, +} from './util'; describe('buildWorkflowSuspensionMessage', () => { const runId = 'test-run-123'; @@ -165,3 +169,93 @@ describe('getWorkflowRunStreamId', () => { expect(result.includes('_user')).toBe(true); }); }); + +describe('getPort', () => { + it('should return undefined or a positive number', async () => { + const port = await getPort(); + expect(port === undefined || typeof port === 'number').toBe(true); + if (port !== undefined) { + expect(port).toBeGreaterThan(0); + } + }); + + it('should return a port number when a server is listening', async () => { + const server = http.createServer(); + + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + + // Give system time to register the port + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const address = server.address(); + + // Port detection may not work immediately in all environments (CI, Docker, etc.) + // so we just verify the function returns a valid result + if (port !== undefined) { + expect(typeof port).toBe('number'); + expect(port).toBeGreaterThan(0); + + // If we have the address, optionally verify it matches + if (address && typeof address === 'object') { + // In most cases it should match, but not required for test to pass + expect([port, undefined]).toContain(port); + } + } + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('should return the smallest port when multiple servers are listening', async () => { + const server1 = http.createServer(); + const server2 = http.createServer(); + + await new Promise((resolve) => { + server1.listen(0, () => resolve()); + }); + + await new Promise((resolve) => { + server2.listen(0, () => resolve()); + }); + + // Give system time to register the ports + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const addr1 = server1.address(); + const addr2 = server2.address(); + + // Port detection may not work in all environments + if ( + port !== undefined && + addr1 && + typeof addr1 === 'object' && + addr2 && + typeof addr2 === 'object' + ) { + // Should return the smallest port + expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); + expect(port).toBeGreaterThan(0); + } else { + // If port detection doesn't work in this environment, just pass + expect(port === undefined || typeof port === 'number').toBe(true); + } + } finally { + await Promise.all([ + new Promise((resolve, reject) => { + server1.close((err) => (err ? reject(err) : resolve())); + }), + new Promise((resolve, reject) => { + server2.close((err) => (err ? reject(err) : resolve())); + }), + ]); + } + }); +}); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 2f470ce204..63e2042203 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,3 +1,5 @@ +import { pidToPorts } from 'pid-port'; + /** * Builds a workflow suspension log message based on the counts of steps, hooks, and waits. * @param runId - The workflow run ID @@ -61,4 +63,20 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) { 'base64url' ); return `${streamId}_${encodedNamespace}`; -} \ No newline at end of file +} + +/** + * Gets the port number that the process is listening on. + * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. + * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) + */ +export async function getPort(): Promise { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; +} diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index a2a8559210..0049be38fa 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -1,6 +1,6 @@ import { runInContext } from 'node:vm'; import { ERROR_SLUGS } from '@workflow/errors'; -import { getPort, withResolvers } from '@workflow/utils'; +import { withResolvers } from '@workflow/utils'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; @@ -21,7 +21,7 @@ import { } from './symbols.js'; import * as Attribute from './telemetry/semantic-conventions.js'; import { trace } from './telemetry.js'; -import { getWorkflowRunStreamId } from './util.js'; +import { getPort, getWorkflowRunStreamId } from './util.js'; import { createContext } from './vm/index.js'; import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; diff --git a/packages/utils/package.json b/packages/utils/package.json index ed493d0936..565fef496b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,7 +36,6 @@ "vitest": "catalog:" }, "dependencies": { - "ms": "2.1.3", - "pid-port": "^2.0.0" + "ms": "2.1.3" } } diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/index.test.ts index 6608efe123..60fb88c179 100644 --- a/packages/utils/src/index.test.ts +++ b/packages/utils/src/index.test.ts @@ -1,6 +1,5 @@ -import http from 'node:http'; import { describe, expect, it } from 'vitest'; -import { getPort, once, parseDurationToDate, withResolvers } from './index'; +import { once, parseDurationToDate, withResolvers } from './index'; describe('parseDurationToDate', () => { it('should parse duration strings correctly', () => { @@ -74,93 +73,3 @@ describe('once', () => { expect(first).toBe(second); }); }); - -describe('getPort', () => { - it('should return undefined or a positive number', async () => { - const port = await getPort(); - expect(port === undefined || typeof port === 'number').toBe(true); - if (port !== undefined) { - expect(port).toBeGreaterThan(0); - } - }); - - it('should return a port number when a server is listening', async () => { - const server = http.createServer(); - - await new Promise((resolve) => { - server.listen(0, () => resolve()); - }); - - // Give system time to register the port - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const address = server.address(); - - // Port detection may not work immediately in all environments (CI, Docker, etc.) - // so we just verify the function returns a valid result - if (port !== undefined) { - expect(typeof port).toBe('number'); - expect(port).toBeGreaterThan(0); - - // If we have the address, optionally verify it matches - if (address && typeof address === 'object') { - // In most cases it should match, but not required for test to pass - expect([port, undefined]).toContain(port); - } - } - } finally { - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - } - }); - - it('should return the smallest port when multiple servers are listening', async () => { - const server1 = http.createServer(); - const server2 = http.createServer(); - - await new Promise((resolve) => { - server1.listen(0, () => resolve()); - }); - - await new Promise((resolve) => { - server2.listen(0, () => resolve()); - }); - - // Give system time to register the ports - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const addr1 = server1.address(); - const addr2 = server2.address(); - - // Port detection may not work in all environments - if ( - port !== undefined && - addr1 && - typeof addr1 === 'object' && - addr2 && - typeof addr2 === 'object' - ) { - // Should return the smallest port - expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); - expect(port).toBeGreaterThan(0); - } else { - // If port detection doesn't work in this environment, just pass - expect(port === undefined || typeof port === 'number').toBe(true); - } - } finally { - await Promise.all([ - new Promise((resolve, reject) => { - server1.close((err) => (err ? reject(err) : resolve())); - }), - new Promise((resolve, reject) => { - server2.close((err) => (err ? reject(err) : resolve())); - }), - ]); - } - }); -}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 375f7edda0..6e657c9060 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,5 @@ import type { StringValue } from 'ms'; import ms from 'ms'; -import { pidToPorts } from 'pid-port'; export interface PromiseWithResolvers { promise: Promise; @@ -86,18 +85,3 @@ export function parseDurationToDate(param: StringValue | Date | number): Date { ); } } - -/** - * Gets the port number that the process is listening on. - * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. - */ -export async function getPort(): Promise { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { - return undefined; - } - - const smallest = Math.min(...ports); - return smallest; -} diff --git a/packages/world-local/package.json b/packages/world-local/package.json index e3d82230d1..ff2a050b83 100644 --- a/packages/world-local/package.json +++ b/packages/world-local/package.json @@ -31,8 +31,9 @@ }, "dependencies": { "@vercel/queue": "0.0.0-alpha.23", - "@workflow/world": "workspace:*", "@workflow/utils": "workspace:*", + "@workflow/world": "workspace:*", + "pid-port": "^2.0.0", "ulid": "^3.0.1", "undici": "^6.19.0", "zod": "catalog:" diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 07e0c40398..f2887e7d9e 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import { JsonTransport } from '@vercel/queue'; -import { getPort } from '@workflow/utils'; +import { pidToPorts } from 'pid-port'; import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; @@ -172,3 +172,19 @@ export function createQueue(port?: number): Queue { return { queue, createQueueHandler, getDeploymentId }; } + +/** + * Gets the port number that the process is listening on. + * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. + * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) + */ +export async function getPort(): Promise { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 422f845e8c..1fb472c9b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,39 +6,12 @@ settings: catalogs: default: - '@biomejs/biome': - specifier: ^2.2.7 - version: 2.3.3 '@types/node': specifier: 22.19.0 version: 22.19.0 - '@vercel/functions': - specifier: ^3.1.4 - version: 3.1.4 - '@vercel/oidc': - specifier: ^3.0.3 - version: 3.0.3 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4 - ai: - specifier: 5.0.76 - version: 5.0.76 - esbuild: - specifier: ^0.25.11 - version: 0.25.11 - nitro: - specifier: npm:nitro-nightly@3.0.1-20251031-202656-883af4f9 - version: 3.0.1-20251031-202656-883af4f9 - typescript: - specifier: ^5.9.3 - version: 5.9.3 vitest: specifier: ^3.2.4 version: 3.2.4 - zod: - specifier: 4.1.11 - version: 4.1.11 overrides: rfc6902: 5.1.2 @@ -640,9 +613,6 @@ importers: ms: specifier: 2.1.3 version: 2.1.3 - pid-port: - specifier: ^2.0.0 - version: 2.0.0 devDependencies: '@types/ms': specifier: ^2.1.0 @@ -863,6 +833,9 @@ importers: '@workflow/world': specifier: workspace:* version: link:../world + pid-port: + specifier: ^2.0.0 + version: 2.0.0 ulid: specifier: ^3.0.1 version: 3.0.1 @@ -14333,6 +14306,14 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 @@ -15343,6 +15324,10 @@ snapshots: better-sqlite3: 11.10.0 drizzle-orm: 0.31.4(@opentelemetry/api@1.9.0)(@types/react@19.1.13)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)(react@19.2.0) + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -19939,7 +19924,7 @@ snapshots: vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) @@ -20073,14 +20058,14 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 expect-type: 1.2.2 magic-string: 0.30.19 pathe: 2.0.3 From 53d903873c75699f6d8cb2c0375dd2e64c394a21 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 15:57:26 -0800 Subject: [PATCH 17/32] test: simplify sveltekit test --- .github/workflows/tests.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bbe9dd4d3e..8d1074970e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -254,18 +254,10 @@ jobs: APP_NAME: ${{ matrix.app.name }} - name: Run E2E Tests - if: matrix.app.name != 'sveltekit' run: cd workbench/${{ matrix.app.name }} && pnpm start & echo "starting tests in 10 seconds" && sleep 10 && pnpm run test:e2e env: APP_NAME: ${{ matrix.app.name }} - DEPLOYMENT_URL: "http://localhost:3000" - - - name: Run E2E Tests (Different Port) - if: matrix.app.name == 'sveltekit' - run: cd workbench/${{ matrix.app.name }} && pnpm start & echo "starting tests in 10 seconds" && sleep 10 && pnpm run test:e2e - env: - APP_NAME: ${{ matrix.app.name }} - DEPLOYMENT_URL: "http://localhost:4173" + DEPLOYMENT_URL: ${{ matrix.app.name == 'sveltekit' && 'http://localhost:4173' || 'http://localhost:3000' }} e2e-windows: name: E2E Windows Tests From 5563d520e4b223882770ef2399c2bef5cacf7bed Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 19:18:09 -0800 Subject: [PATCH 18/32] fix missing import in test --- packages/core/src/util.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 35e99dbc80..f1631809b9 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,3 +1,4 @@ +import http from 'node:http'; import { describe, expect, it } from 'vitest'; import { buildWorkflowSuspensionMessage, From 90721755a03e13192fb86b028916c98d7f64c804 Mon Sep 17 00:00:00 2001 From: Adrian Lam Date: Thu, 6 Nov 2025 20:02:33 -0800 Subject: [PATCH 19/32] fix: async await stuff with getPort --- packages/core/src/runtime.ts | 7 +++++-- packages/core/src/util.ts | 18 ++++++++++++------ packages/core/src/workflow.ts | 6 +++++- packages/world-local/src/queue.ts | 18 ++++++++++++------ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 23e7a7885c..64c6ed8d64 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -8,7 +8,6 @@ import { WorkflowRunNotCompletedError, WorkflowRuntimeError, } from '@workflow/errors'; -import { getPort } from './util.js'; import type { Event, WorkflowRun, @@ -40,6 +39,7 @@ import { serializeTraceCarrier, trace, withTraceContext } from './telemetry.js'; import { getErrorName, getErrorStack } from './types.js'; import { buildWorkflowSuspensionMessage, + getPort, getWorkflowRunStreamId, } from './util.js'; import { runWorkflow } from './workflow.js'; @@ -563,6 +563,9 @@ export const stepEntrypoint = const stepName = metadata.queueName.slice('__wkf_step_'.length); const world = getWorld(); + // Get the port early to avoid async operations during step execution + const port = await getPort(); + return trace(`STEP ${stepName}`, async (span) => { span?.setAttributes({ ...Attribute.StepName(stepName), @@ -673,7 +676,7 @@ export const stepEntrypoint = // solution only works for vercel + embedded worlds. url: process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${await getPort()}`, + : `http://localhost:${port ?? 3000}`, }, ops, }, diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 63e2042203..b147293a1c 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -71,12 +71,18 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) { * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) */ export async function getPort(): Promise { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { + try { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; + } catch { + // If port detection fails (e.g., `ss` command not available in production), + // return undefined and fall back to default port return undefined; } - - const smallest = Math.min(...ports); - return smallest; } diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 0049be38fa..cd4592d5ec 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -48,6 +48,10 @@ export async function runWorkflow( ); } + // Get the port before creating VM context to avoid async operations + // affecting the deterministic timestamp + const port = await getPort(); + const { context, globalThis: vmGlobalThis, @@ -101,7 +105,7 @@ export async function runWorkflow( // solution only works for vercel + embedded worlds. const url = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${await getPort()}`; + : `http://localhost:${port ?? 3000}`; // For the workflow VM, we store the context in a symbol on the `globalThis` object const ctx: WorkflowMetadata = { diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index f2887e7d9e..99c4523608 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -179,12 +179,18 @@ export function createQueue(port?: number): Queue { * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) */ export async function getPort(): Promise { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { + try { + const pid = process.pid; + const ports = await pidToPorts(pid); + if (!ports || ports.size === 0) { + return undefined; + } + + const smallest = Math.min(...ports); + return smallest; + } catch { + // If port detection fails (e.g., `ss` command not available in production), + // return undefined and fall back to default port return undefined; } - - const smallest = Math.min(...ports); - return smallest; } From 27a12f4d18eb81be023989a649c0faa73bf0779f Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 7 Nov 2025 18:24:57 +0530 Subject: [PATCH 20/32] feat(utils,core,world-local): add port detection and baseUrl support --- packages/core/package.json | 1 - packages/core/src/runtime.ts | 2 +- packages/core/src/util.ts | 24 ------------- packages/core/src/workflow.ts | 3 +- packages/utils/package.json | 7 +++- packages/utils/src/node.ts | 43 ++++++++++++++++++++++++ packages/world-local/package.json | 1 - packages/world-local/src/config.ts | 50 ++++++++++++++++++++++----- packages/world-local/src/index.ts | 24 ++++++------- packages/world-local/src/queue.ts | 31 +++-------------- pnpm-lock.yaml | 54 ++++++++++++++++++------------ 11 files changed, 141 insertions(+), 99 deletions(-) create mode 100644 packages/utils/src/node.ts diff --git a/packages/core/package.json b/packages/core/package.json index 864f3d799b..213032871a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,7 +59,6 @@ "devalue": "^5.4.1", "ms": "2.1.3", "nanoid": "^5.1.6", - "pid-port": "^2.0.0", "seedrandom": "^3.0.5", "ulid": "^3.0.1", "zod": "catalog:" diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 64c6ed8d64..43f497019e 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -8,6 +8,7 @@ import { WorkflowRunNotCompletedError, WorkflowRuntimeError, } from '@workflow/errors'; +import { getPort } from '@workflow/utils/node'; import type { Event, WorkflowRun, @@ -39,7 +40,6 @@ import { serializeTraceCarrier, trace, withTraceContext } from './telemetry.js'; import { getErrorName, getErrorStack } from './types.js'; import { buildWorkflowSuspensionMessage, - getPort, getWorkflowRunStreamId, } from './util.js'; import { runWorkflow } from './workflow.js'; diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index b147293a1c..5c13219493 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,5 +1,3 @@ -import { pidToPorts } from 'pid-port'; - /** * Builds a workflow suspension log message based on the counts of steps, hooks, and waits. * @param runId - The workflow run ID @@ -64,25 +62,3 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) { ); return `${streamId}_${encodedNamespace}`; } - -/** - * Gets the port number that the process is listening on. - * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. - * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) - */ -export async function getPort(): Promise { - try { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { - return undefined; - } - - const smallest = Math.min(...ports); - return smallest; - } catch { - // If port detection fails (e.g., `ss` command not available in production), - // return undefined and fall back to default port - return undefined; - } -} diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index cd4592d5ec..5b38467901 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -1,6 +1,7 @@ import { runInContext } from 'node:vm'; import { ERROR_SLUGS } from '@workflow/errors'; import { withResolvers } from '@workflow/utils'; +import { getPort } from '@workflow/utils/node'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; @@ -21,7 +22,7 @@ import { } from './symbols.js'; import * as Attribute from './telemetry/semantic-conventions.js'; import { trace } from './telemetry.js'; -import { getPort, getWorkflowRunStreamId } from './util.js'; +import { getWorkflowRunStreamId } from './util.js'; import { createContext } from './vm/index.js'; import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 565fef496b..67de50b6d8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,6 +20,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" } }, "scripts": { @@ -36,6 +40,7 @@ "vitest": "catalog:" }, "dependencies": { - "ms": "2.1.3" + "ms": "2.1.3", + "pid-port": "^2.0.0" } } diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts new file mode 100644 index 0000000000..25937f032f --- /dev/null +++ b/packages/utils/src/node.ts @@ -0,0 +1,43 @@ +/** + * Node.js-only utilities + * + * These utilities require Node.js APIs and cannot be used in the workflow runtime VM. + * Import from '@workflow/utils/node' to use these functions. + */ + +import { pidToPorts } from 'pid-port'; + +/** + * Gets the port number that the current Node.js process is listening on. + * + * Uses process introspection to detect which port the application is using. + * This is useful for frameworks that use non-standard ports (SvelteKit, Vite, etc.) + * + * @returns The smallest port number the process is listening on, or undefined if not listening on any port + * + * @example + * ```typescript + * const port = await getPort(); + * if (port) { + * console.log(`Server is listening on port ${port}`); + * } + * ``` + */ +export async function getPort(): Promise { + try { + const pid = process.pid; + const ports = await pidToPorts(pid); + + if (!ports || ports.size === 0) { + return undefined; + } + + // Return the smallest port number + const smallest = Math.min(...ports); + return smallest; + } catch { + // If port detection fails (e.g., `ss` command not available in production), + // return undefined and fall back to default port + return undefined; + } +} diff --git a/packages/world-local/package.json b/packages/world-local/package.json index ff2a050b83..61ef20cf0e 100644 --- a/packages/world-local/package.json +++ b/packages/world-local/package.json @@ -33,7 +33,6 @@ "@vercel/queue": "0.0.0-alpha.23", "@workflow/utils": "workspace:*", "@workflow/world": "workspace:*", - "pid-port": "^2.0.0", "ulid": "^3.0.1", "undici": "^6.19.0", "zod": "catalog:" diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 1e0768c1df..8a6b6eb03c 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -1,3 +1,4 @@ +import { getPort } from '@workflow/utils/node'; import { once } from './util.js'; const getDataDirFromEnv = () => { @@ -6,17 +7,48 @@ const getDataDirFromEnv = () => { export const DEFAULT_RESOLVE_DATA_OPTION = 'all'; -const getPortFromEnv = () => { - const port = process.env.PORT; - if (port) { - return Number(port); - } - return undefined; +const getBaseUrlFromEnv = () => { + return process.env.WORKFLOW_EMBEDDED_BASE_URL; +}; + +export type Config = { + dataDir: string; + port?: number; + baseUrl?: string; }; -export const config = once(() => { +export const config = once(() => { const dataDir = getDataDirFromEnv(); - const port = getPortFromEnv(); + const baseUrl = getBaseUrlFromEnv(); - return { dataDir, port }; + return { dataDir, baseUrl }; }); + +/** + * Resolves the base URL for queue requests following the priority order: + * 1. config.baseUrl (highest priority - full override from args or WORKFLOW_EMBEDDED_BASE_URL env var) + * 2. config.port (explicit port override from args) + * 3. Auto-detected port via pid-port (primary approach) + * 4. PORT env var (fallback) + * 5. Fallback to 3000 + */ +export async function resolveBaseUrl(config: Partial): Promise { + if (config.baseUrl) { + return config.baseUrl; + } + + if (config.port) { + return `http://localhost:${config.port}`; + } + + const detectedPort = await getPort(); + if (detectedPort) { + return `http://localhost:${detectedPort}`; + } + + if (process.env.PORT) { + return `http://localhost:${process.env.PORT}`; + } + + return 'http://localhost:3000'; +} diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 239dd6968f..59b980612e 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -1,4 +1,5 @@ import type { World } from '@workflow/world'; +import type { Config } from './config.js'; import { config } from './config.js'; import { createQueue } from './queue.js'; import { createStorage } from './storage.js'; @@ -7,21 +8,16 @@ import { createStreamer } from './streamer.js'; /** * Creates an embedded world instance that combines queue, storage, and streamer functionalities. * - * @param dataDir - The directory to use for storage. If not provided, the default data dir will be used. - * @param port - The port to use for the queue. If not provided, the default port will be used. + * @param args - Optional configuration object + * @param args.dataDir - Directory for storing workflow data (default: `.workflow-data/`) + * @param args.port - Port override for queue transport (default: auto-detected) + * @param args.baseUrl - Full base URL override for queue transport (default: `http://localhost:{port}`) */ -export function createEmbeddedWorld({ - dataDir, - port, -}: { - dataDir?: string; - port?: number; -}): World { - const dir = dataDir ?? config.value.dataDir; - const queuePort = port ?? config.value.port; +export function createEmbeddedWorld(args?: Partial): World { + const mergedConfig = { ...config.value, ...(args ?? {}) }; return { - ...createQueue(queuePort), - ...createStorage(dir), - ...createStreamer(dir), + ...createQueue(mergedConfig), + ...createStorage(mergedConfig.dataDir), + ...createStreamer(mergedConfig.dataDir), }; } diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 99c4523608..df1f465edf 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,10 +1,11 @@ import { setTimeout } from 'node:timers/promises'; import { JsonTransport } from '@vercel/queue'; -import { pidToPorts } from 'pid-port'; import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; import z from 'zod'; +import type { Config } from './config.js'; +import { resolveBaseUrl } from './config.js'; // For local queue, there is no technical limit on the message visibility lifespan, // but the environment variable can be used for testing purposes to set a max visibility limit. @@ -17,7 +18,7 @@ const httpAgent = new Agent({ headersTimeout: 0, }); -export function createQueue(port?: number): Queue { +export function createQueue(config: Partial): Queue { const transport = new JsonTransport(); const generateId = monotonicFactory(); @@ -58,12 +59,12 @@ export function createQueue(port?: number): Queue { (async () => { let defaultRetriesLeft = 3; - const portToUse = port ?? (await getPort()); + const baseUrl = await resolveBaseUrl(config); for (let attempt = 0; defaultRetriesLeft > 0; attempt++) { defaultRetriesLeft--; const response = await fetch( - `http://localhost:${portToUse}/.well-known/workflow/v1/${pathname}`, + `${baseUrl}/.well-known/workflow/v1/${pathname}`, { method: 'POST', duplex: 'half', @@ -172,25 +173,3 @@ export function createQueue(port?: number): Queue { return { queue, createQueueHandler, getDeploymentId }; } - -/** - * Gets the port number that the process is listening on. - * @returns The port number that the process is listening on, or undefined if the process is not listening on any port. - * NOTE: Can't move this to @workflow/utils because it's being imported into @workflow/errors for RetryableError (inside workflow runtime) - */ -export async function getPort(): Promise { - try { - const pid = process.pid; - const ports = await pidToPorts(pid); - if (!ports || ports.size === 0) { - return undefined; - } - - const smallest = Math.min(...ports); - return smallest; - } catch { - // If port detection fails (e.g., `ss` command not available in production), - // return undefined and fall back to default port - return undefined; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fb472c9b2..0c1408ff87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,39 @@ settings: catalogs: default: + '@biomejs/biome': + specifier: ^2.2.7 + version: 2.3.3 '@types/node': specifier: 22.19.0 version: 22.19.0 + '@vercel/functions': + specifier: ^3.1.4 + version: 3.1.4 + '@vercel/oidc': + specifier: ^3.0.3 + version: 3.0.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4 + ai: + specifier: 5.0.76 + version: 5.0.76 + esbuild: + specifier: ^0.25.11 + version: 0.25.11 + nitro: + specifier: npm:nitro-nightly@3.0.1-20251031-202656-883af4f9 + version: 3.0.1-20251031-202656-883af4f9 + typescript: + specifier: ^5.9.3 + version: 5.9.3 vitest: specifier: ^3.2.4 version: 3.2.4 + zod: + specifier: 4.1.11 + version: 4.1.11 overrides: rfc6902: 5.1.2 @@ -423,9 +450,6 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.6 - pid-port: - specifier: ^2.0.0 - version: 2.0.0 seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -613,6 +637,9 @@ importers: ms: specifier: 2.1.3 version: 2.1.3 + pid-port: + specifier: ^2.0.0 + version: 2.0.0 devDependencies: '@types/ms': specifier: ^2.1.0 @@ -833,9 +860,6 @@ importers: '@workflow/world': specifier: workspace:* version: link:../world - pid-port: - specifier: ^2.0.0 - version: 2.0.0 ulid: specifier: ^3.0.1 version: 3.0.1 @@ -14306,14 +14330,6 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.19 - optionalDependencies: - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 @@ -15324,10 +15340,6 @@ snapshots: better-sqlite3: 11.10.0 drizzle-orm: 0.31.4(@opentelemetry/api@1.9.0)(@types/react@19.1.13)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)(react@19.2.0) - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -19924,7 +19936,7 @@ snapshots: vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0) @@ -20058,14 +20070,14 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.2.2 magic-string: 0.30.19 pathe: 2.0.3 From 34039c3d655b6bda7d8a2000fd76f4d59f80aa70 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 7 Nov 2025 18:25:14 +0530 Subject: [PATCH 21/32] docs: document port detection and baseUrl features --- .changeset/smooth-rats-attack.md | 8 +- .../docs/deploying/world/embedded-world.mdx | 87 ++++++++++++++++--- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md index ee702f4b9b..4cd89096b9 100644 --- a/.changeset/smooth-rats-attack.md +++ b/.changeset/smooth-rats-attack.md @@ -3,4 +3,10 @@ "@workflow/world-local": patch --- -Add automatic port discovery +Add automatic port detection and baseUrl configuration support + +- Automatically detect application port using pid-port (no manual configuration needed) +- Support HTTPS and custom hostnames via baseUrl override +- Add WORKFLOW_EMBEDDED_BASE_URL environment variable +- Refactor createEmbeddedWorld API to accept Partial for better flexibility +- Implement priority-based URL resolution: explicit baseUrl → config port → auto-detected port → PORT env var → 3000 fallback diff --git a/docs/content/docs/deploying/world/embedded-world.mdx b/docs/content/docs/deploying/world/embedded-world.mdx index 2872ee241e..d4a2f7ae27 100644 --- a/docs/content/docs/deploying/world/embedded-world.mdx +++ b/docs/content/docs/deploying/world/embedded-world.mdx @@ -72,25 +72,63 @@ export WORKFLOW_EMBEDDED_DATA_DIR=./custom-workflow-data ```typescript import { createEmbeddedWorld } from '@workflow/world-local'; -const world = createEmbeddedWorld('./custom-workflow-data'); +const world = createEmbeddedWorld({ dataDir: './custom-workflow-data' }); ``` ### Port -The local world automatically detects your server port from the `PORT` environment variable: +By default, the embedded world **automatically detects** which port your application is listening on using process introspection. This works seamlessly with frameworks like SvelteKit, Vite, and others that use non-standard ports. -```bash -export PORT=3000 +**Auto-detection example** (recommended): -npm run dev +```typescript +import { createEmbeddedWorld } from '@workflow/world-local'; + +// Port is automatically detected - no configuration needed! +const world = createEmbeddedWorld(); ``` -You can also specify it explicitly when creating the world programmatically: +If auto-detection fails, the world will fall back to the `PORT` environment variable, then to port `3000`. + +**Manual port override** (when needed): + +You can override the auto-detected port by specifying it explicitly: ```typescript import { createEmbeddedWorld } from '@workflow/world-local'; -const world = createEmbeddedWorld(undefined, 3000); +const world = createEmbeddedWorld({ port: 3000 }); +``` + +### Base URL + +For advanced use cases like HTTPS or custom hostnames, you can override the entire base URL. When set, this takes precedence over all port detection and configuration. + +**Use cases:** +- HTTPS dev servers (e.g., `next dev --experimental-https`) +- Custom hostnames (e.g., `local.example.com`) +- Non-localhost development + +**Environment variable:** + +```bash +export WORKFLOW_EMBEDDED_BASE_URL=https://local.example.com:3000 +``` + +**Programmatically:** + +```typescript +import { createEmbeddedWorld } from '@workflow/world-local'; + +// HTTPS +const world = createEmbeddedWorld({ + baseUrl: 'https://localhost:3000' +}); + +// Custom hostname +const world = createEmbeddedWorld({ + baseUrl: 'https://local.example.com:3000' +}); ``` ## Usage @@ -172,26 +210,49 @@ Creates a local world instance: ```typescript function createEmbeddedWorld( - dataDir?: string, - port?: number + args?: Partial<{ + dataDir: string; + port: number; + baseUrl: string; + }> ): World ``` **Parameters:** -- `dataDir` - Directory for storing workflow data (default: `.workflow-data/`) -- `port` - Server port for queue transport (default: from `PORT` env var) +- `args` - Optional configuration object: + - `dataDir` - Directory for storing workflow data (default: `.workflow-data/` or `WORKFLOW_EMBEDDED_DATA_DIR` env var) + - `port` - Port override for queue transport (default: auto-detected → `PORT` env var → `3000`) + - `baseUrl` - Full base URL override for queue transport (default: `http://localhost:{port}` or `WORKFLOW_EMBEDDED_BASE_URL` env var) **Returns:** - `World` - A world instance implementing the World interface -**Example:** +**Examples:** ```typescript import { createEmbeddedWorld } from '@workflow/world-local'; -const world = createEmbeddedWorld('./my-data', 3000); +// Use all defaults (recommended - auto-detects port) +const world = createEmbeddedWorld(); + +// Custom data directory +const world = createEmbeddedWorld({ dataDir: './my-data' }); + +// Override port +const world = createEmbeddedWorld({ port: 3000 }); + +// HTTPS with custom hostname +const world = createEmbeddedWorld({ + baseUrl: 'https://local.example.com:3000' +}); + +// Multiple options +const world = createEmbeddedWorld({ + dataDir: './my-data', + baseUrl: 'https://localhost:3000' +}); ``` ## Learn More From deccc27f7759f5497e526393effd4be9a2064063 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 7 Nov 2025 18:34:12 +0530 Subject: [PATCH 22/32] DCO Remediation Commit for Saatvik Arya I, Saatvik Arya , hereby add my Signed-off-by to this commit: 27a12f4d18eb81be023989a649c0faa73bf0779f I, Saatvik Arya , hereby add my Signed-off-by to this commit: 34039c3d655b6bda7d8a2000fd76f4d59f80aa70 Signed-off-by: Saatvik Arya From aead6c9be24656365e82fa493a7fc2d437766785 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 7 Nov 2025 18:47:38 +0530 Subject: [PATCH 23/32] test(world-local,utils,core): add comprehensive tests for port detection and config Signed-off-by: Saatvik Arya --- packages/core/src/util.test.ts | 97 +-------- packages/utils/src/node.test.ts | 93 +++++++++ packages/world-local/src/config.test.ts | 250 ++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 96 deletions(-) create mode 100644 packages/utils/src/node.test.ts create mode 100644 packages/world-local/src/config.test.ts diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index f1631809b9..8163fec83d 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,10 +1,5 @@ -import http from 'node:http'; import { describe, expect, it } from 'vitest'; -import { - buildWorkflowSuspensionMessage, - getPort, - getWorkflowRunStreamId, -} from './util'; +import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util'; describe('buildWorkflowSuspensionMessage', () => { const runId = 'test-run-123'; @@ -170,93 +165,3 @@ describe('getWorkflowRunStreamId', () => { expect(result.includes('_user')).toBe(true); }); }); - -describe('getPort', () => { - it('should return undefined or a positive number', async () => { - const port = await getPort(); - expect(port === undefined || typeof port === 'number').toBe(true); - if (port !== undefined) { - expect(port).toBeGreaterThan(0); - } - }); - - it('should return a port number when a server is listening', async () => { - const server = http.createServer(); - - await new Promise((resolve) => { - server.listen(0, () => resolve()); - }); - - // Give system time to register the port - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const address = server.address(); - - // Port detection may not work immediately in all environments (CI, Docker, etc.) - // so we just verify the function returns a valid result - if (port !== undefined) { - expect(typeof port).toBe('number'); - expect(port).toBeGreaterThan(0); - - // If we have the address, optionally verify it matches - if (address && typeof address === 'object') { - // In most cases it should match, but not required for test to pass - expect([port, undefined]).toContain(port); - } - } - } finally { - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - } - }); - - it('should return the smallest port when multiple servers are listening', async () => { - const server1 = http.createServer(); - const server2 = http.createServer(); - - await new Promise((resolve) => { - server1.listen(0, () => resolve()); - }); - - await new Promise((resolve) => { - server2.listen(0, () => resolve()); - }); - - // Give system time to register the ports - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const addr1 = server1.address(); - const addr2 = server2.address(); - - // Port detection may not work in all environments - if ( - port !== undefined && - addr1 && - typeof addr1 === 'object' && - addr2 && - typeof addr2 === 'object' - ) { - // Should return the smallest port - expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); - expect(port).toBeGreaterThan(0); - } else { - // If port detection doesn't work in this environment, just pass - expect(port === undefined || typeof port === 'number').toBe(true); - } - } finally { - await Promise.all([ - new Promise((resolve, reject) => { - server1.close((err) => (err ? reject(err) : resolve())); - }), - new Promise((resolve, reject) => { - server2.close((err) => (err ? reject(err) : resolve())); - }), - ]); - } - }); -}); diff --git a/packages/utils/src/node.test.ts b/packages/utils/src/node.test.ts new file mode 100644 index 0000000000..709b806100 --- /dev/null +++ b/packages/utils/src/node.test.ts @@ -0,0 +1,93 @@ +import http from 'node:http'; +import { describe, expect, it } from 'vitest'; +import { getPort } from './node'; + +describe('getPort', () => { + it('should return undefined or a positive number', async () => { + const port = await getPort(); + expect(port === undefined || typeof port === 'number').toBe(true); + if (port !== undefined) { + expect(port).toBeGreaterThan(0); + } + }); + + it('should return a port number when a server is listening', async () => { + const server = http.createServer(); + + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + + // Give system time to register the port + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const address = server.address(); + + // Port detection may not work immediately in all environments (CI, Docker, etc.) + // so we just verify the function returns a valid result + if (port !== undefined) { + expect(typeof port).toBe('number'); + expect(port).toBeGreaterThan(0); + + // If we have the address, optionally verify it matches + if (address && typeof address === 'object') { + // In most cases it should match, but not required for test to pass + expect([port, undefined]).toContain(port); + } + } + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('should return the smallest port when multiple servers are listening', async () => { + const server1 = http.createServer(); + const server2 = http.createServer(); + + await new Promise((resolve) => { + server1.listen(0, () => resolve()); + }); + + await new Promise((resolve) => { + server2.listen(0, () => resolve()); + }); + + // Give system time to register the ports + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const port = await getPort(); + const addr1 = server1.address(); + const addr2 = server2.address(); + + // Port detection may not work in all environments + if ( + port !== undefined && + addr1 && + typeof addr1 === 'object' && + addr2 && + typeof addr2 === 'object' + ) { + // Should return the smallest port + expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); + expect(port).toBeGreaterThan(0); + } else { + // If port detection doesn't work in this environment, just pass + expect(port === undefined || typeof port === 'number').toBe(true); + } + } finally { + await Promise.all([ + new Promise((resolve, reject) => { + server1.close((err) => (err ? reject(err) : resolve())); + }), + new Promise((resolve, reject) => { + server2.close((err) => (err ? reject(err) : resolve())); + }), + ]); + } + }); +}); diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts new file mode 100644 index 0000000000..b6217c1efd --- /dev/null +++ b/packages/world-local/src/config.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveBaseUrl } from './config'; + +// Mock the getPort function from @workflow/utils/node +vi.mock('@workflow/utils/node', () => ({ + getPort: vi.fn(), +})); + +describe('resolveBaseUrl', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.clearAllMocks(); + }); + + describe('priority order', () => { + it('should prioritize config.baseUrl over all other options', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + process.env.PORT = '8080'; + + const result = await resolveBaseUrl({ + baseUrl: 'https://custom.example.com:3000', + port: 4000, + }); + + expect(result).toBe('https://custom.example.com:3000'); + expect(getPort).not.toHaveBeenCalled(); + }); + + it('should use config.port when baseUrl is not provided', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + process.env.PORT = '8080'; + + const result = await resolveBaseUrl({ + port: 4000, + }); + + expect(result).toBe('http://localhost:4000'); + expect(getPort).not.toHaveBeenCalled(); + }); + + it('should auto-detect port when neither baseUrl nor port is provided', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + process.env.PORT = '8080'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:5173'); + expect(getPort).toHaveBeenCalled(); + }); + + it('should use PORT env var when auto-detection returns undefined', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + process.env.PORT = '8080'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:8080'); + expect(getPort).toHaveBeenCalled(); + }); + + it('should fallback to 3000 when all detection methods fail', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + delete process.env.PORT; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000'); + expect(getPort).toHaveBeenCalled(); + }); + }); + + describe('baseUrl configuration', () => { + it('should support HTTPS URLs', async () => { + const result = await resolveBaseUrl({ + baseUrl: 'https://localhost:3000', + }); + + expect(result).toBe('https://localhost:3000'); + }); + + it('should support custom hostnames', async () => { + const result = await resolveBaseUrl({ + baseUrl: 'https://local.example.com:3000', + }); + + expect(result).toBe('https://local.example.com:3000'); + }); + + it('should support non-standard ports in baseUrl', async () => { + const result = await resolveBaseUrl({ + baseUrl: 'http://localhost:8888', + }); + + expect(result).toBe('http://localhost:8888'); + }); + + it('should support baseUrl without port', async () => { + const result = await resolveBaseUrl({ + baseUrl: 'https://example.com', + }); + + expect(result).toBe('https://example.com'); + }); + }); + + describe('port configuration', () => { + it('should construct URL with port when provided', async () => { + const result = await resolveBaseUrl({ + port: 5173, + }); + + expect(result).toBe('http://localhost:5173'); + }); + + it('should handle port 80', async () => { + const result = await resolveBaseUrl({ + port: 80, + }); + + expect(result).toBe('http://localhost:80'); + }); + + it('should handle high port numbers', async () => { + const result = await resolveBaseUrl({ + port: 65535, + }); + + expect(result).toBe('http://localhost:65535'); + }); + }); + + describe('auto-detection', () => { + it('should use auto-detected port for SvelteKit default (5173)', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:5173'); + }); + + it('should use auto-detected port for Vite default (5173)', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:5173'); + }); + + it('should use auto-detected port for Next.js default (3000)', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(3000); + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000'); + }); + + it('should handle auto-detection failure gracefully', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + delete process.env.PORT; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000'); + }); + }); + + describe('environment variables', () => { + it('should use PORT env var as fallback', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + process.env.PORT = '4173'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:4173'); + }); + + it('should ignore PORT env var when config.port is provided', async () => { + const { getPort } = await import('@workflow/utils/node'); + process.env.PORT = '4173'; + + const result = await resolveBaseUrl({ + port: 5000, + }); + + expect(result).toBe('http://localhost:5000'); + expect(getPort).not.toHaveBeenCalled(); + }); + + it('should ignore PORT env var when config.baseUrl is provided', async () => { + const { getPort } = await import('@workflow/utils/node'); + process.env.PORT = '4173'; + + const result = await resolveBaseUrl({ + baseUrl: 'https://example.com', + }); + + expect(result).toBe('https://example.com'); + expect(getPort).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle empty config object', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + delete process.env.PORT; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000'); + }); + + it('should handle undefined config', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(undefined); + delete process.env.PORT; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000'); + }); + + it('should handle config with only dataDir', async () => { + const { getPort } = await import('@workflow/utils/node'); + vi.mocked(getPort).mockResolvedValue(5173); + + const result = await resolveBaseUrl({ + dataDir: './custom-data', + }); + + expect(result).toBe('http://localhost:5173'); + }); + }); +}); From 195a7ae926fc450f616c5b6cb37bec0a959dcc25 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:17:16 +0530 Subject: [PATCH 24/32] rm utils/node --- packages/utils/src/node.test.ts | 93 --------------------------------- packages/utils/src/node.ts | 43 --------------- 2 files changed, 136 deletions(-) delete mode 100644 packages/utils/src/node.test.ts delete mode 100644 packages/utils/src/node.ts diff --git a/packages/utils/src/node.test.ts b/packages/utils/src/node.test.ts deleted file mode 100644 index 709b806100..0000000000 --- a/packages/utils/src/node.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import http from 'node:http'; -import { describe, expect, it } from 'vitest'; -import { getPort } from './node'; - -describe('getPort', () => { - it('should return undefined or a positive number', async () => { - const port = await getPort(); - expect(port === undefined || typeof port === 'number').toBe(true); - if (port !== undefined) { - expect(port).toBeGreaterThan(0); - } - }); - - it('should return a port number when a server is listening', async () => { - const server = http.createServer(); - - await new Promise((resolve) => { - server.listen(0, () => resolve()); - }); - - // Give system time to register the port - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const address = server.address(); - - // Port detection may not work immediately in all environments (CI, Docker, etc.) - // so we just verify the function returns a valid result - if (port !== undefined) { - expect(typeof port).toBe('number'); - expect(port).toBeGreaterThan(0); - - // If we have the address, optionally verify it matches - if (address && typeof address === 'object') { - // In most cases it should match, but not required for test to pass - expect([port, undefined]).toContain(port); - } - } - } finally { - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - } - }); - - it('should return the smallest port when multiple servers are listening', async () => { - const server1 = http.createServer(); - const server2 = http.createServer(); - - await new Promise((resolve) => { - server1.listen(0, () => resolve()); - }); - - await new Promise((resolve) => { - server2.listen(0, () => resolve()); - }); - - // Give system time to register the ports - await new Promise((resolve) => setTimeout(resolve, 100)); - - try { - const port = await getPort(); - const addr1 = server1.address(); - const addr2 = server2.address(); - - // Port detection may not work in all environments - if ( - port !== undefined && - addr1 && - typeof addr1 === 'object' && - addr2 && - typeof addr2 === 'object' - ) { - // Should return the smallest port - expect(port).toBeLessThanOrEqual(Math.max(addr1.port, addr2.port)); - expect(port).toBeGreaterThan(0); - } else { - // If port detection doesn't work in this environment, just pass - expect(port === undefined || typeof port === 'number').toBe(true); - } - } finally { - await Promise.all([ - new Promise((resolve, reject) => { - server1.close((err) => (err ? reject(err) : resolve())); - }), - new Promise((resolve, reject) => { - server2.close((err) => (err ? reject(err) : resolve())); - }), - ]); - } - }); -}); diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts deleted file mode 100644 index 25937f032f..0000000000 --- a/packages/utils/src/node.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Node.js-only utilities - * - * These utilities require Node.js APIs and cannot be used in the workflow runtime VM. - * Import from '@workflow/utils/node' to use these functions. - */ - -import { pidToPorts } from 'pid-port'; - -/** - * Gets the port number that the current Node.js process is listening on. - * - * Uses process introspection to detect which port the application is using. - * This is useful for frameworks that use non-standard ports (SvelteKit, Vite, etc.) - * - * @returns The smallest port number the process is listening on, or undefined if not listening on any port - * - * @example - * ```typescript - * const port = await getPort(); - * if (port) { - * console.log(`Server is listening on port ${port}`); - * } - * ``` - */ -export async function getPort(): Promise { - try { - const pid = process.pid; - const ports = await pidToPorts(pid); - - if (!ports || ports.size === 0) { - return undefined; - } - - // Return the smallest port number - const smallest = Math.min(...ports); - return smallest; - } catch { - // If port detection fails (e.g., `ss` command not available in production), - // return undefined and fall back to default port - return undefined; - } -} From 414aa4c3b18d760ff612172045160eed0228caea Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:19:42 +0530 Subject: [PATCH 25/32] add changeset --- .changeset/chilly-yaks-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilly-yaks-sneeze.md diff --git a/.changeset/chilly-yaks-sneeze.md b/.changeset/chilly-yaks-sneeze.md new file mode 100644 index 0000000000..d121176ee8 --- /dev/null +++ b/.changeset/chilly-yaks-sneeze.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-local": minor +--- + +add baseUrl support to config From 7b81781f1f4cc51582bfca3d8068b625dda25a82 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:25:10 +0530 Subject: [PATCH 26/32] Update config tests to mock getPort from the correct module '@workflow/utils/get-port' --- packages/world-local/src/config.test.ts | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index b6217c1efd..ea8f90f770 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveBaseUrl } from './config'; -// Mock the getPort function from @workflow/utils/node -vi.mock('@workflow/utils/node', () => ({ +// Mock the getPort function from @workflow/utils/get-port +vi.mock('@workflow/utils/get-port', () => ({ getPort: vi.fn(), })); @@ -20,7 +20,7 @@ describe('resolveBaseUrl', () => { describe('priority order', () => { it('should prioritize config.baseUrl over all other options', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); process.env.PORT = '8080'; @@ -34,7 +34,7 @@ describe('resolveBaseUrl', () => { }); it('should use config.port when baseUrl is not provided', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); process.env.PORT = '8080'; @@ -47,7 +47,7 @@ describe('resolveBaseUrl', () => { }); it('should auto-detect port when neither baseUrl nor port is provided', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); process.env.PORT = '8080'; @@ -58,7 +58,7 @@ describe('resolveBaseUrl', () => { }); it('should use PORT env var when auto-detection returns undefined', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); process.env.PORT = '8080'; @@ -69,7 +69,7 @@ describe('resolveBaseUrl', () => { }); it('should fallback to 3000 when all detection methods fail', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); delete process.env.PORT; @@ -142,7 +142,7 @@ describe('resolveBaseUrl', () => { describe('auto-detection', () => { it('should use auto-detected port for SvelteKit default (5173)', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); const result = await resolveBaseUrl({}); @@ -151,7 +151,7 @@ describe('resolveBaseUrl', () => { }); it('should use auto-detected port for Vite default (5173)', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); const result = await resolveBaseUrl({}); @@ -160,7 +160,7 @@ describe('resolveBaseUrl', () => { }); it('should use auto-detected port for Next.js default (3000)', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(3000); const result = await resolveBaseUrl({}); @@ -169,7 +169,7 @@ describe('resolveBaseUrl', () => { }); it('should handle auto-detection failure gracefully', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); delete process.env.PORT; @@ -181,7 +181,7 @@ describe('resolveBaseUrl', () => { describe('environment variables', () => { it('should use PORT env var as fallback', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); process.env.PORT = '4173'; @@ -191,7 +191,7 @@ describe('resolveBaseUrl', () => { }); it('should ignore PORT env var when config.port is provided', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); process.env.PORT = '4173'; const result = await resolveBaseUrl({ @@ -203,7 +203,7 @@ describe('resolveBaseUrl', () => { }); it('should ignore PORT env var when config.baseUrl is provided', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); process.env.PORT = '4173'; const result = await resolveBaseUrl({ @@ -217,7 +217,7 @@ describe('resolveBaseUrl', () => { describe('edge cases', () => { it('should handle empty config object', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); delete process.env.PORT; @@ -227,7 +227,7 @@ describe('resolveBaseUrl', () => { }); it('should handle undefined config', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(undefined); delete process.env.PORT; @@ -237,7 +237,7 @@ describe('resolveBaseUrl', () => { }); it('should handle config with only dataDir', async () => { - const { getPort } = await import('@workflow/utils/node'); + const { getPort } = await import('@workflow/utils/get-port'); vi.mocked(getPort).mockResolvedValue(5173); const result = await resolveBaseUrl({ From 30a5e069227b7ba635d109d034516e72da6aee01 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:30:12 +0530 Subject: [PATCH 27/32] DCO Remediation Commit for Saatvik Arya I, Saatvik Arya , hereby add my Signed-off-by to this commit: 195a7ae926fc450f616c5b6cb37bec0a959dcc25 I, Saatvik Arya , hereby add my Signed-off-by to this commit: 414aa4c3b18d760ff612172045160eed0228caea I, Saatvik Arya , hereby add my Signed-off-by to this commit: 7b81781f1f4cc51582bfca3d8068b625dda25a82 Signed-off-by: Saatvik Arya From 4d5d25328b1133cf5d6daaede2d72456e35b1958 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:37:20 +0530 Subject: [PATCH 28/32] fix(world-local): handle port 0 and null correctly in config - Change port check from truthiness to `typeof === 'number'` - Port 0 (OS-assigned port) now works correctly instead of being skipped - Null values properly fall through to auto-detection - Add tests for port 0 and null edge cases Signed-off-by: Saatvik Arya --- packages/world-local/src/config.test.ts | 23 +++++++++++++++++++++++ packages/world-local/src/config.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index ea8f90f770..6ea983dd7a 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -123,6 +123,17 @@ describe('resolveBaseUrl', () => { expect(result).toBe('http://localhost:5173'); }); + it('should handle port 0 (OS-assigned port)', async () => { + const { getPort } = await import('@workflow/utils/get-port'); + + const result = await resolveBaseUrl({ + port: 0, + }); + + expect(result).toBe('http://localhost:0'); + expect(getPort).not.toHaveBeenCalled(); + }); + it('should handle port 80', async () => { const result = await resolveBaseUrl({ port: 80, @@ -246,5 +257,17 @@ describe('resolveBaseUrl', () => { expect(result).toBe('http://localhost:5173'); }); + + it('should skip null port and use auto-detection', async () => { + const { getPort } = await import('@workflow/utils/get-port'); + vi.mocked(getPort).mockResolvedValue(5173); + + const result = await resolveBaseUrl({ + port: null as any, + }); + + expect(result).toBe('http://localhost:5173'); + expect(getPort).toHaveBeenCalled(); + }); }); }); diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index ec3c83d022..e36264d5e4 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -37,7 +37,7 @@ export async function resolveBaseUrl(config: Partial): Promise { return config.baseUrl; } - if (config.port) { + if (typeof config.port === 'number') { return `http://localhost:${config.port}`; } From 2c759319ffdbd699a1a4f9d401abf4389afd2057 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:40:39 +0530 Subject: [PATCH 29/32] chore: update changeset to major for breaking API change The createEmbeddedWorld API signature change is breaking: - Before: createEmbeddedWorld(dataDir?, port?) - After: createEmbeddedWorld(args?: Partial) Signed-off-by: Saatvik Arya --- .changeset/chilly-yaks-sneeze.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.changeset/chilly-yaks-sneeze.md b/.changeset/chilly-yaks-sneeze.md index d121176ee8..86b4157875 100644 --- a/.changeset/chilly-yaks-sneeze.md +++ b/.changeset/chilly-yaks-sneeze.md @@ -1,5 +1,16 @@ --- -"@workflow/world-local": minor +"@workflow/world-local": major --- -add baseUrl support to config +BREAKING: Change `createEmbeddedWorld` API signature from positional parameters to config object. Add baseUrl configuration support and fix port 0 handling. + +**Breaking change:** +- `createEmbeddedWorld(dataDir?, port?)` → `createEmbeddedWorld(args?: Partial)` + +**New features:** +- Add `baseUrl` config option for HTTPS and custom hostnames +- Automatic port detection via `@workflow/utils/get-port` + +**Bug fixes:** +- Port 0 (OS-assigned port) now works correctly +- Null port values properly fall through to auto-detection From 2e363cbdedc1c344891a4f6fbf1d08cfed3d3fd0 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:43:48 +0530 Subject: [PATCH 30/32] chore: remove bug fixes section from changeset These were bugs introduced and fixed within the same PR, not pre-existing bugs, so they shouldn't be listed as fixes. Signed-off-by: Saatvik Arya --- .changeset/chilly-yaks-sneeze.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.changeset/chilly-yaks-sneeze.md b/.changeset/chilly-yaks-sneeze.md index 86b4157875..897532c7f7 100644 --- a/.changeset/chilly-yaks-sneeze.md +++ b/.changeset/chilly-yaks-sneeze.md @@ -2,7 +2,7 @@ "@workflow/world-local": major --- -BREAKING: Change `createEmbeddedWorld` API signature from positional parameters to config object. Add baseUrl configuration support and fix port 0 handling. +BREAKING: Change `createEmbeddedWorld` API signature from positional parameters to config object. Add baseUrl configuration support. **Breaking change:** - `createEmbeddedWorld(dataDir?, port?)` → `createEmbeddedWorld(args?: Partial)` @@ -10,7 +10,4 @@ BREAKING: Change `createEmbeddedWorld` API signature from positional parameters **New features:** - Add `baseUrl` config option for HTTPS and custom hostnames - Automatic port detection via `@workflow/utils/get-port` - -**Bug fixes:** -- Port 0 (OS-assigned port) now works correctly -- Null port values properly fall through to auto-detection +- Support for port 0 (OS-assigned port) From 2397c0138ac41c3f234474694bd40f684c212bcd Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 9 Nov 2025 01:45:22 +0530 Subject: [PATCH 31/32] chore: remove auto port detection from new features Automatic port detection already existed in main via getPort(). This PR only adds baseUrl config and port 0 support. Signed-off-by: Saatvik Arya --- .changeset/chilly-yaks-sneeze.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/chilly-yaks-sneeze.md b/.changeset/chilly-yaks-sneeze.md index 897532c7f7..c2ad707f18 100644 --- a/.changeset/chilly-yaks-sneeze.md +++ b/.changeset/chilly-yaks-sneeze.md @@ -8,6 +8,5 @@ BREAKING: Change `createEmbeddedWorld` API signature from positional parameters - `createEmbeddedWorld(dataDir?, port?)` → `createEmbeddedWorld(args?: Partial)` **New features:** -- Add `baseUrl` config option for HTTPS and custom hostnames -- Automatic port detection via `@workflow/utils/get-port` +- Add `baseUrl` config option for HTTPS and custom hostnames (via config or `WORKFLOW_EMBEDDED_BASE_URL` env var) - Support for port 0 (OS-assigned port) From 94d5e51e7bbc60ea8b4c1b11ff88e272ea58b1f9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Thu, 13 Nov 2025 19:48:12 +0530 Subject: [PATCH 32/32] refactor(world-local): improve argument handling in createEmbeddedWorld - Updated createEmbeddedWorld to filter out undefined values from args before merging with the default config. - This change ensures that only defined arguments are considered, enhancing the function's robustness. Signed-off-by: Saatvik Arya --- packages/world-local/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 59b980612e..60564202de 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -14,7 +14,12 @@ import { createStreamer } from './streamer.js'; * @param args.baseUrl - Full base URL override for queue transport (default: `http://localhost:{port}`) */ export function createEmbeddedWorld(args?: Partial): World { - const mergedConfig = { ...config.value, ...(args ?? {}) }; + const definedArgs = args + ? Object.fromEntries( + Object.entries(args).filter(([, value]) => value !== undefined) + ) + : {}; + const mergedConfig = { ...config.value, ...definedArgs }; return { ...createQueue(mergedConfig), ...createStorage(mergedConfig.dataDir),