diff --git a/.changeset/smooth-rats-attack.md b/.changeset/smooth-rats-attack.md new file mode 100644 index 00000000..091cecc3 --- /dev/null +++ b/.changeset/smooth-rats-attack.md @@ -0,0 +1,7 @@ +--- +"@workflow/core": patch +"@workflow/utils": patch +"@workflow/world-local": patch +--- + +Add automatic port discovery diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1fe6080c..766751cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -188,7 +188,7 @@ jobs: run: cd workbench/${{ matrix.app.name }} && pnpm dev & echo "starting tests in 10 seconds" && sleep 10 && pnpm vitest run packages/core/e2e/dev.test.ts && pnpm run test:e2e env: APP_NAME: ${{ matrix.app.name }} - DEPLOYMENT_URL: "http://localhost:${{ matrix.app.port }}" + DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '5173' || '3000' }}" DEV_TEST_CONFIG: ${{ toJSON(matrix.app) }} e2e-local-prod: @@ -240,7 +240,7 @@ jobs: 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" + DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || '3000' }}" e2e-windows: name: E2E Windows Tests diff --git a/docs/content/docs/getting-started/sveltekit.mdx b/docs/content/docs/getting-started/sveltekit.mdx index 1c15df29..a4b4aa19 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. diff --git a/packages/core/package.json b/packages/core/package.json index 864f3d79..21303287 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 66a5b561..615d9bb7 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/get-port'; import type { Event, WorkflowRun, @@ -562,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), @@ -672,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:${process.env.PORT || 3000}`, + : `http://localhost:${port ?? 3000}`, }, ops, }, diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 5b35d958..664944ee 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -40,7 +40,6 @@ export const createWorld = (): World => { if (targetWorld === 'embedded') { return createEmbeddedWorld({ dataDir: process.env.WORKFLOW_EMBEDDED_DATA_DIR, - port: process.env.PORT ? Number(process.env.PORT) : undefined, }); } diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 8163fec8..3cd5c9e6 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, getWorkflowRunStreamId } from './util'; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 6f0495ee..a9e11aae 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/get-port'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; @@ -48,6 +49,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 +106,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:${process.env.PORT || 3000}`; + : `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/utils/package.json b/packages/utils/package.json index 565fef49..815bd58b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,6 +20,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./get-port": { + "types": "./dist/get-port.d.ts", + "default": "./dist/get-port.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/get-port.test.ts b/packages/utils/src/get-port.test.ts new file mode 100644 index 00000000..fe1d94df --- /dev/null +++ b/packages/utils/src/get-port.test.ts @@ -0,0 +1,80 @@ +import http from 'node:http'; +import { describe, expect, it } from 'vitest'; +import { getPort } from './get-port'; + +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(); + + server.listen(0); + + 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(); + + server1.listen(0); + server2.listen(0); + + 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/get-port.ts b/packages/utils/src/get-port.ts new file mode 100644 index 00000000..ac225d8e --- /dev/null +++ b/packages/utils/src/get-port.ts @@ -0,0 +1,23 @@ +import { pidToPorts } from 'pid-port'; + +/** + * 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/world-local/package.json b/packages/world-local/package.json index f6526922..61ef20cf 100644 --- a/packages/world-local/package.json +++ b/packages/world-local/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@vercel/queue": "0.0.0-alpha.23", + "@workflow/utils": "workspace:*", "@workflow/world": "workspace:*", "ulid": "^3.0.1", "undici": "^6.19.0", diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index b3b28154..1e0768c1 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(() => { diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 5ea1fd34..a5a25a62 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,5 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import { JsonTransport } from '@vercel/queue'; +import { getPort } from '@workflow/utils/get-port'; import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; @@ -57,11 +58,12 @@ export function createQueue(port?: number): Queue { (async () => { let defaultRetriesLeft = 3; + 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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b867445..3bd84c37 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 @@ -640,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 @@ -854,6 +854,9 @@ 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 @@ -9771,46 +9774,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} @@ -14116,7 +14079,7 @@ snapshots: '@types/debug@4.1.12': dependencies: - '@types/ms': 2.1.0 + '@types/ms': 0.7.34 '@types/deep-eql@4.0.2': {} @@ -14379,13 +14342,21 @@ 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@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.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@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 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + 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: @@ -20073,40 +20044,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 @@ -20153,7 +20090,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@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 @@ -20171,7 +20108,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: @@ -20195,7 +20132,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 @@ -20213,7 +20150,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: diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index 7489c685..2e439c18 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -5,35 +5,30 @@ const DEV_TEST_CONFIGS = { generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js', apiFilePath: 'app/api/chat/route.ts', apiFileImportPath: '../../..', - port: 3000, }, 'nextjs-webpack': { generatedStepPath: 'app/.well-known/workflow/v1/step/route.js', generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js', apiFilePath: 'app/api/chat/route.ts', apiFileImportPath: '../../..', - port: 3000, }, nitro: { generatedStepPath: '.nitro/workflow/steps.mjs', generatedWorkflowPath: '.nitro/workflow/workflows.mjs', apiFilePath: 'routes/api/chat.post.ts', apiFileImportPath: '../..', - port: 3000, }, sveltekit: { generatedStepPath: 'src/routes/.well-known/workflow/v1/step/+server.js', generatedWorkflowPath: 'src/routes/.well-known/workflow/v1/flow/+server.js', apiFilePath: 'src/routes/api/chat/+server.ts', apiFileImportPath: '../../../..', - port: 3000, }, vite: { generatedStepPath: 'dist/workflow/steps.mjs', generatedWorkflowPath: 'dist/workflow/workflows.mjs', apiFilePath: 'src/main.ts', apiFileImportPath: '..', - port: 3000, }, }; diff --git a/workbench/sveltekit/package.json b/workbench/sveltekit/package.json index 7669e757..9a22456a 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"