diff --git a/.changeset/chilly-yaks-sneeze.md b/.changeset/chilly-yaks-sneeze.md new file mode 100644 index 0000000000..c2ad707f18 --- /dev/null +++ b/.changeset/chilly-yaks-sneeze.md @@ -0,0 +1,12 @@ +--- +"@workflow/world-local": major +--- + +BREAKING: Change `createEmbeddedWorld` API signature from positional parameters to config object. Add baseUrl configuration support. + +**Breaking change:** +- `createEmbeddedWorld(dataDir?, port?)` → `createEmbeddedWorld(args?: Partial)` + +**New features:** +- 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) diff --git a/docs/content/docs/deploying/world/local-world.mdx b/docs/content/docs/deploying/world/local-world.mdx index 23442e8bdc..8356454cdf 100644 --- a/docs/content/docs/deploying/world/local-world.mdx +++ b/docs/content/docs/deploying/world/local-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 diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts new file mode 100644 index 0000000000..6ea983dd7a --- /dev/null +++ b/packages/world-local/src/config.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveBaseUrl } from './config'; + +// Mock the getPort function from @workflow/utils/get-port +vi.mock('@workflow/utils/get-port', () => ({ + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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 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, + }); + + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + 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/get-port'); + vi.mocked(getPort).mockResolvedValue(5173); + + const result = await resolveBaseUrl({ + dataDir: './custom-data', + }); + + 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 1e0768c1df..e36264d5e4 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -1,3 +1,4 @@ +import { getPort } from '@workflow/utils/get-port'; 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 (typeof config.port === 'number') { + 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..60564202de 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,21 @@ 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 definedArgs = args + ? Object.fromEntries( + Object.entries(args).filter(([, value]) => value !== undefined) + ) + : {}; + const mergedConfig = { ...config.value, ...definedArgs }; 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 a5a25a6227..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 { getPort } from '@workflow/utils/get-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',