diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index a312052..da009ff 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -2,8 +2,7 @@ name: pr-validation on: pull_request: - types: [opened, reopened, edited] - # only applies to PRs that want to merge to main + types: [opened, reopened, edited, synchronize] branches: [main] jobs: diff --git a/README.md b/README.md index f1cdd12..0c2d284 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,11 @@ sf plugins Start a local development proxy server for webapp development with Salesforce authentication. +**Two operating modes:** + +- **Command mode** (default): When `dev.command` is set in `webapplication.json` (or default `npm run dev`), the CLI starts the dev server. URL defaults to `http://localhost:5173`; override with `dev.url` or `--url` if needed. +- **URL-only mode**: When only `dev.url` or `--url` is provided (no command), the CLI assumes the dev server is already running and does not start it. Proxy only. + ```bash USAGE $ sf webapp dev --name --target-org [options] @@ -138,50 +143,29 @@ REQUIRED FLAGS -o, --target-org= Salesforce org to authenticate against OPTIONAL FLAGS - -u, --url= Dev server URL (overrides webapplication.json) + -u, --url= Dev server URL. Command mode: override default 5173. URL-only: required (server must be running) -p, --port= Proxy server port (default: 4545) --open Open browser automatically -GLOBAL FLAGS - --flags-dir= Import flag values from a directory - --json Format output as json - DESCRIPTION - Start a local development proxy server for webapp development. - - This command starts a local HTTP proxy server that handles Salesforce - authentication and routes requests between your local dev server and - Salesforce APIs. It automatically spawns and monitors your dev server, - detects the URL, and provides health monitoring. + Starts a local HTTP proxy that injects Salesforce authentication and routes + requests between your dev server and Salesforce APIs. In command mode, + spawns and monitors the dev server (default URL: localhost:5173). In + URL-only mode, connects to an already-running dev server. EXAMPLES - Start proxy with automatic dev server management: + Command mode (CLI starts dev server, default port 5173): $ sf webapp dev --name myapp --target-org myorg --open - Use existing dev server: + URL-only mode (dev server already running): $ sf webapp dev --name myapp --target-org myorg --url http://localhost:5173 --open - Use custom proxy port: + Custom proxy port: $ sf webapp dev --name myapp --target-org myorg --port 8080 --open -SUPPORTED DEV SERVERS - - Vite - - Create React App (Webpack) - - Next.js - - Any server that outputs http://localhost:PORT - -FEATURES - - Automatic Salesforce authentication injection - - Intelligent request routing (Salesforce vs dev server) - - WebSocket support for Hot Module Replacement (HMR) - - Beautiful HTML error pages with auto-refresh - - Periodic health monitoring (every 5s) - - Configuration file watching (webapplication.json) - - Graceful shutdown on Ctrl+C - SEE ALSO - Complete Guide: SF_WEBAPP_DEV_GUIDE.md ``` diff --git a/SF_WEBAPP_DEV_GUIDE.md b/SF_WEBAPP_DEV_GUIDE.md index 78f17e0..4095bce 100644 --- a/SF_WEBAPP_DEV_GUIDE.md +++ b/SF_WEBAPP_DEV_GUIDE.md @@ -234,38 +234,43 @@ Browser → Proxy → [Auth Headers Injected] → Salesforce → Response ## Configuration -### webapplication.json Schema +### Dev Server URL Resolution -The `webapplication.json` file is **optional**. All fields are also optional - missing fields use defaults. +The command operates in two distinct modes based on configuration: -#### Dev Configuration +| Mode | Configuration | Behavior | +|------|---------------|----------| +| **Command mode** | `dev.command` is set (or default `npm run dev`) | CLI starts the dev server. URL defaults to `http://localhost:5173`. Override with `dev.url` or `--url` if your dev server uses a different port. | +| **URL-only mode** | `dev.url` or `--url` only (no `dev.command`) | CLI assumes the dev server is already running. Does **not** start the dev server. Starts proxy only and forwards to the given URL. | -| Field | Type | Description | Default | -| ------------- | ------ | ------------------------------------- | ------------------------- | -| `dev.command` | string | Command to start dev server | `npm run dev` | -| `dev.url` | string | Dev server URL (when already running) | `http://localhost:5173` | +**URL precedence:** `--url` flag > `dev.url` in manifest > default `http://localhost:5173` (when command is used) -All fields are optional. Only specify what you need to override. +### webapplication.json Schema -**Option A: No manifest (uses defaults)** +The `webapplication.json` file is **optional**. All fields are also optional - missing fields use defaults. -If no `webapplication.json` exists: +#### Dev Configuration -- Dev command: `npm run dev` -- Name: folder name -- Manifest watching: disabled +| Field | Type | Description | Default | +| ------------- | ------ | ----------- | ------- | +| `dev.command` | string | Command to start the dev server (e.g., `npm run dev`). When set, the CLI starts the dev server and uses default URL `http://localhost:5173` unless overridden. | `npm run dev` | +| `dev.url` | string | Dev server URL. **Command mode**: Override the default 5173 port if needed. **URL-only mode**: Required—the CLI assumes the server is already running and does not start it. | `http://localhost:5173` | -**Option B: Minimal manifest** +**Command mode (CLI starts dev server):** ```json { "dev": { - "command": "npm start" + "command": "npm run dev" } } ``` -**Option C: Explicit URL (dev server already running)** +- CLI runs `npm run dev` and waits for the server to be ready +- Default URL: `http://localhost:5173` +- Override port: add `"url": "http://localhost:3000"` if your dev server uses a different port + +**URL-only mode (proxy only, server already running):** ```json { @@ -275,7 +280,15 @@ If no `webapplication.json` exists: } ``` -Use this when you want to start the dev server yourself. +- No `dev.command` — CLI does **not** start the dev server +- You must start the dev server yourself (e.g., `npm run dev` in another terminal) +- CLI starts only the proxy and forwards to the given URL + +**No manifest (uses defaults):** + +- Dev command: `npm run dev` +- Default URL: `http://localhost:5173` +- Manifest watching: disabled #### Routing Configuration (Optional) @@ -375,19 +388,19 @@ Automatically detects Salesforce Code Builder environment and binds to `0.0.0.0` ## The `--url` Flag -The `--url` flag provides control over which dev server URL the proxy uses. It has smart behavior depending on whether the URL is already available. +The `--url` flag overrides the dev server URL. Behavior depends on whether you have a command configured: -### Behavior +| Scenario | Command in manifest? | `--url` behavior | +|----------|----------------------|------------------| +| URL-only mode | No | Required. CLI assumes the server is already running and does not start it. Use when you run the dev server yourself. | +| Command mode | Yes | Optional override. Default is `http://localhost:5173`. Use `--url` to point to a different port. | +| URL reachable | Either | Proxy-only: skips starting dev server, starts proxy only | +| URL not reachable | Yes (command) | Starts dev server and warns if actual URL differs from `--url` | +| URL not reachable | No (URL-only) | Error: server must be running at the given URL | -| Scenario | What Happens | -| ------------------------ | ----------------------------------------------------------------- | -| `--url` is reachable | **Proxy-only mode**: Skips starting dev server, only starts proxy | -| `--url` is NOT reachable | Starts dev server, warns if actual URL differs from `--url` | -| No `--url` provided | Starts dev server automatically, detects URL | +### Connect to Existing Dev Server (Proxy-Only Mode) -### Use Case 1: Connect to Existing Dev Server (Proxy-Only Mode) - -If you prefer to manage your dev server separately: +When you run the dev server yourself: ```bash # Terminal 1: Start your dev server manually @@ -404,36 +417,18 @@ sf webapp dev --url http://localhost:5173 --target-org myOrg ``` ✅ URL http://localhost:5173 is already available, skipping dev server startup (proxy-only mode) ✅ Ready for development! - → Proxy: http://localhost:4545 - → Dev server: http://localhost:5173 + → http://localhost:4545 (open this URL in your browser) ``` -### Use Case 2: URL Mismatch Warning +### Override Default Port (Command Mode) -If you specify a `--url` that doesn't match where the dev server actually starts: +When using `dev.command`, the default URL is `http://localhost:5173`. Override with `--url` if your dev server uses a different port: ```bash -# No dev server running, specify wrong port -sf webapp dev --url http://localhost:9999 --target-org myOrg +sf webapp dev --url http://localhost:3000 --target-org myOrg ``` -**Output:** - -``` -Warning: ⚠️ The --url flag (http://localhost:9999) does not match the actual dev server URL (http://localhost:5173/). -The proxy will use the actual dev server URL. -``` - -The command continues working with the actual dev server URL. - -### Important Notes - -- The `--url` flag checks **only** the URL you specify, not other ports -- If you have a dev server on port 5173 but specify `--url http://localhost:9999`: - - Command checks 9999 → not available - - Starts a NEW dev server → may get port 5174 (if 5173 is taken) - - Warns about mismatch (9999 ≠ 5174) -- To use an existing dev server, specify its **exact** URL with `--url` +If the URL is not reachable, the CLI starts the dev server and uses the actual URL (with a warning if it differs). --- diff --git a/messages/webapp.dev.md b/messages/webapp.dev.md index 4a9b9de..2ba040f 100644 --- a/messages/webapp.dev.md +++ b/messages/webapp.dev.md @@ -96,12 +96,44 @@ Proxy URL: %s (open this URL in your browser) # info.press-ctrl-c -Press Ctrl+C to stop the proxy server +Press Ctrl+C to stop. + +# info.press-ctrl-c-target + +Press Ctrl+C to stop the %s. + +# info.stopped-target + +✅ Stopped %s. + +# info.stop-target-dev + +dev server + +# info.stop-target-proxy + +proxy server + +# info.stop-target-both + +dev and proxy servers # info.server-running Dev server is running. Stop it by running "SFDX: Close Live Preview" from the VS Code command palette. +# info.server-running-target-dev + +Dev server is running. Stop it by running "SFDX: Close Live Preview" from the VS Code command palette. + +# info.server-running-target-proxy + +Proxy server is running. Stop it by running "SFDX: Close Live Preview" from the VS Code command palette. + +# info.server-running-target-both + +Dev and proxy servers are running. Stop them by running "SFDX: Close Live Preview" from the VS Code command palette. + # info.dev-server-healthy ✓ Dev server is responding at: %s @@ -139,6 +171,15 @@ dev.command changed to "%s" - restart the command to apply this change. Failed to watch manifest: %s +# error.dev-url-unreachable + +Dev server unreachable at %s. +Start your dev server manually at that URL, or add dev.command to webapplication.json to start it automatically. + +# error.port-in-use + +Port %s is already in use. Try specifying a different port with the --port flag or stopping the service that's using the port. + # error.dev-server-failed %s @@ -192,14 +233,3 @@ The proxy will use the actual dev server URL. Vite WebApp proxy detected at %s - using Vite's built-in proxy (standalone proxy skipped) -# info.stopped-proxy-only - -✅ Stopped proxy server. - -# info.stopped-dev-only - -✅ Stopped dev server. - -# info.stopped-dev-and-proxy - -✅ Stopped dev & proxy servers. diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index 786831c..b7d7631 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -50,7 +50,7 @@ export default class WebappDev extends SfCommand { summary: messages.getMessage('flags.port.summary'), description: messages.getMessage('flags.port.description'), char: 'p', - default: 4545, + required: false, }), 'target-org': Flags.requiredOrg(), open: Flags.boolean({ @@ -118,6 +118,31 @@ export default class WebappDev extends SfCommand { } } + /** + * Poll a URL until it is reachable or timeout. + * + * @param url - URL to poll (HEAD request) + * @param timeoutMs - Max time to wait + * @param intervalMs - Poll interval + * @param start - Start timestamp (for recursion) + * @returns true if reachable within timeout + */ + private static async pollUntilReachable( + url: string, + timeoutMs: number, + intervalMs = 500, + start = Date.now() + ): Promise { + if (await WebappDev.isUrlReachable(url)) { + return true; + } + if (Date.now() - start >= timeoutMs) { + return false; + } + await new Promise((r) => setTimeout(r, intervalMs)); + return WebappDev.pollUntilReachable(url, timeoutMs, intervalMs, start); + } + /** * Check if Vite's WebAppProxyHandler is active at the dev server URL. * The Vite plugin responds to a health check query parameter with a custom header @@ -238,110 +263,125 @@ export default class WebappDev extends SfCommand { }); } else { // No manifest - log applied defaults for troubleshooting - this.log(messages.getMessage('info.no-manifest-defaults', [DEFAULT_DEV_COMMAND, String(flags.port)])); + const defaultPort = flags.port ?? 4545; + this.log(messages.getMessage('info.no-manifest-defaults', [DEFAULT_DEV_COMMAND, String(defaultPort)])); this.log(''); this.log(messages.getMessage('info.starting-webapp', [selectedWebapp.name])); } - // Step 3: Determine dev server URL - // Track whether we should skip starting dev server (when --url is already reachable) - let skipDevServer = false; - let explicitUrlProvided = false; - - // Handle --url flag: check if URL is already reachable before starting dev server - if (flags.url) { - explicitUrlProvided = true; - this.logger.debug(`Checking if explicit URL is reachable: ${flags.url}`); - - const isReachable = await WebappDev.isUrlReachable(flags.url); + // Step 3: Resolve dev server URL (config-driven, no stdout parsing) + // Priority: --url > dev.url > (dev.command or no-manifest or no dev config ? default localhost:5173 : throw) + // Use default URL when: no manifest, no dev section, no dev.command, or dev.command is non-empty + const hasExplicitCommand = Boolean(manifest?.dev?.command?.trim()); + const hasDevCommand = !selectedWebapp.hasManifest || !manifest?.dev?.command || hasExplicitCommand; + const resolvedUrl = flags.url ?? manifest?.dev?.url ?? (hasDevCommand ? 'http://localhost:5173' : null); + if (!resolvedUrl) { + throw new SfError( + '❌ Unable to determine dev server URL. Specify --url or configure dev.url or dev.command in webapplication.json.', + 'DevServerUrlError' + ); + } - if (isReachable) { - // URL is already available - skip starting dev server, only start proxy - devServerUrl = flags.url; - skipDevServer = true; - this.log(messages.getMessage('info.url-already-available', [flags.url])); - this.logger.debug(`URL ${flags.url} is reachable, skipping dev server startup`); - } else { - // URL not reachable - will start dev server and check for mismatch later - this.logger.debug(`URL ${flags.url} is not reachable, will start dev server`); + // Check if URL is already reachable + const isReachable = await WebappDev.isUrlReachable(resolvedUrl); + if (isReachable) { + devServerUrl = resolvedUrl; + this.log(messages.getMessage('info.url-already-available', [resolvedUrl])); + this.logger.debug(`URL ${resolvedUrl} is reachable, skipping dev server startup`); + } else if ((flags.url ?? manifest?.dev?.url) && !manifest?.dev?.command?.trim()) { + // Explicit URL (--url or dev.url) but no dev.command - don't start (we can't control the port) + throw new SfError(messages.getMessage('error.dev-url-unreachable', [resolvedUrl]), 'DevServerUrlError', [ + `Ensure your dev server is running at ${resolvedUrl}`, + 'Or add dev.command to webapplication.json to start it automatically', + ]); + } else { + // URL not reachable - we have dev.command (or defaults) to start + const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; + if (!selectedWebapp.hasManifest) { + this.logger.debug(messages.getMessage('info.using-defaults', [devCommand])); } - } - // If we're not skipping dev server, determine how to start it - if (!skipDevServer) { - if (manifest?.dev?.url && !explicitUrlProvided) { - // Use manifest dev.url - devServerUrl = manifest.dev.url; - this.logger.debug(`Using dev server URL from manifest: ${devServerUrl}`); - } else { - // Start dev server with command - const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; + this.logger.debug(`Starting dev server with command: ${devCommand}, url: ${resolvedUrl}`); + this.devServerManager = new DevServerManager({ + command: devCommand, + url: resolvedUrl, + cwd: webappDir, + startupTimeout: 60_000, + }); - if (!selectedWebapp.hasManifest) { - this.logger.debug(messages.getMessage('info.using-defaults', [devCommand])); + let lastDevServerError: (SfError | DevServerError) | null = null; + this.devServerManager.on('error', (error: SfError | DevServerError) => { + lastDevServerError = error; + const devError = + 'devServerError' in error ? (error as SfError & { devServerError?: DevServerError }).devServerError : error; + if ( + devError && + 'stderrLines' in devError && + Array.isArray(devError.stderrLines) && + 'title' in devError && + 'type' in devError + ) { + this.proxyServer?.setActiveDevServerError(devError); } + this.logger?.debug(`Dev server error: ${error.message}`); + }); - // Start dev server from the webapp directory - this.logger.debug(`Starting dev server with command: ${devCommand}`); - this.devServerManager = new DevServerManager({ - command: devCommand, - cwd: webappDir, - startupTimeout: 60_000, // 60 seconds - aligned with VS Code extension - }); - - // Setup dev server event handlers - this.devServerManager.on('ready', (url: string) => { - this.logger?.debug(`Dev server ready at: ${url}`); - // Clear any dev server error when server starts successfully - this.proxyServer?.clearActiveDevServerError(); - }); + this.devServerManager.on('exit', () => { + this.logger?.debug('Dev server stopped'); + }); - this.devServerManager.on('error', (error: SfError | DevServerError) => { - // Set error for proxy to display in browser (if proxy is running) - // Don't log here - the error will be thrown and displayed by the main catch block - if ('stderrLines' in error && Array.isArray(error.stderrLines) && 'title' in error && 'type' in error) { - this.proxyServer?.setActiveDevServerError(error); + this.devServerManager.start(); + + // Poll until URL is reachable, or fail immediately on process error + const pollPromise = WebappDev.pollUntilReachable(resolvedUrl, 60_000); + const errorPromise = new Promise((_, reject) => { + this.devServerManager!.once('error', (error: SfError | DevServerError) => { + const devError = + 'devServerError' in error + ? (error as SfError & { devServerError?: DevServerError }).devServerError + : null; + const suggestions: string[] = [`Try running the command manually to see the error: ${devCommand}`]; + if (devError) { + suggestions.unshift(`Reason: ${devError.title} - ${devError.message}`); + if (devError.suggestions.length > 0) suggestions.push(...devError.suggestions); + } else if ('message' in error) { + suggestions.unshift(`Reason: ${(error as { message: string }).message}`); } - this.logger?.debug(`Dev server error: ${error.message}`); - }); - - this.devServerManager.on('exit', () => { - this.logger?.debug('Dev server stopped'); - }); - - this.devServerManager.start(); - - // Wait for dev server to be ready - const actualDevServerUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject( - new SfError('❌ Dev server did not start within 60 seconds.', 'DevServerTimeoutError', [ - 'The dev server may be taking longer than expected to start', - 'Check if the dev server command is correct in webapplication.json', - 'Try running the dev server command manually to see if it starts', - ]) - ); - }, 60_000); - - this.devServerManager?.on('ready', (url: string) => { - clearTimeout(timeout); - resolve(url); - }); - - this.devServerManager?.on('error', (error: SfError) => { - clearTimeout(timeout); - reject(error); - }); + const lastOutput = this.devServerManager?.getLastOutput(); + if (lastOutput?.trim()) suggestions.push(`Last dev server output:\n${lastOutput}`); + reject(new SfError('❌ Dev server failed to start.', 'DevServerError', suggestions)); }); + }); - // Check for URL mismatch if --url was provided - if (explicitUrlProvided && flags.url && flags.url !== actualDevServerUrl) { - this.warn(messages.getMessage('warning.url-mismatch', [flags.url, actualDevServerUrl])); + const pollReached = await Promise.race([pollPromise, errorPromise]); + if (!pollReached) { + // Timeout - capture context before cleanup nulls devServerManager + const manager = this.devServerManager; + const lastOutput = manager?.getLastOutput() ?? ''; + + const suggestions: string[] = [ + 'The dev server may be taking longer than expected to start', + 'Check if the dev server command is correct in webapplication.json', + `Try running the command manually to see the error: ${devCommand}`, + ]; + const devError = + lastDevServerError && 'devServerError' in lastDevServerError + ? (lastDevServerError as SfError & { devServerError?: DevServerError }).devServerError + : null; + if (devError) { + suggestions.unshift(`Reason: ${devError.title} - ${devError.message}`); + if (devError.suggestions.length > 0) suggestions.push(...devError.suggestions); + } else if (lastDevServerError && 'message' in lastDevServerError) { + suggestions.unshift(`Reason: ${(lastDevServerError as { message: string }).message}`); } + if (lastOutput.trim()) suggestions.push(`Last dev server output:\n${lastOutput}`); - // Use the actual dev server URL - devServerUrl = actualDevServerUrl; + await this.cleanup(); + throw new SfError('❌ Dev server did not start within 60 seconds.', 'DevServerTimeoutError', suggestions); } + + devServerUrl = resolvedUrl; + this.logger?.debug(`Dev server ready at: ${devServerUrl}`); } // Step 4: Get org info for authentication @@ -370,27 +410,54 @@ export default class WebappDev extends SfCommand { this.logger.debug('Vite proxy detected, skipping standalone proxy server'); finalUrl = devServerUrl; } else { - // Start standalone proxy server - this.logger.debug(`Starting proxy server on port ${flags.port}...`); - const salesforceInstanceUrl = orgConnection.instanceUrl; - this.proxyServer = new ProxyServer({ - devServerUrl, - salesforceInstanceUrl, - port: flags.port, - manifest: manifest ?? undefined, - orgAlias: orgUsername, - }); + // Resolve proxy port: --port > dev.port > default 4545 + // If configured and busy: throw. If not configured and busy: try next port. + const portExplicitlyConfigured = flags.port !== undefined || manifest?.dev?.port != null; + const initialProxyPort = flags.port ?? manifest?.dev?.port ?? 4545; + const maxPortAttempts = 10; + const serverUrl = devServerUrl; + + const tryStartProxy = async (port: number, attempt: number): Promise => { + this.logger?.debug(`Starting proxy server on port ${port}...`); + const salesforceInstanceUrl = orgConnection.instanceUrl; + this.proxyServer = new ProxyServer({ + devServerUrl: serverUrl, + salesforceInstanceUrl, + port, + manifest: manifest ?? undefined, + orgAlias: orgUsername, + }); - await this.proxyServer.start(); - const proxyUrl = this.proxyServer.getProxyUrl(); + try { + await this.proxyServer.start(); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EADDRINUSE') { + if (portExplicitlyConfigured) { + throw new SfError(messages.getMessage('error.port-in-use', [String(port)]), 'PortInUseError'); + } + if (attempt >= maxPortAttempts - 1) { + throw error; + } + this.proxyServer = null; + this.logger?.debug(`Port ${port} busy, trying ${port + 1}...`); + return tryStartProxy(port + 1, attempt + 1); + } + throw error; + } + }; + + await tryStartProxy(initialProxyPort, 0); + + const proxyUrl = this.proxyServer!.getProxyUrl(); this.logger.debug(`Proxy server running on ${proxyUrl}`); // Listen for dev server status changes (minimal output) - this.proxyServer.on('dev-server-up', (url: string) => { + this.proxyServer!.on('dev-server-up', (url: string) => { this.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); }); - this.proxyServer.on('dev-server-down', (url: string) => { + this.proxyServer!.on('dev-server-down', (url: string) => { this.log(messages.getMessage('warning.dev-server-unreachable-status', [url])); this.log(messages.getMessage('info.start-dev-server-hint')); }); @@ -398,6 +465,9 @@ export default class WebappDev extends SfCommand { finalUrl = proxyUrl; } + // Emit JSON line to stderr before human messages (CLI-extension contract) + process.stderr.write(JSON.stringify({ url: finalUrl }) + '\n'); + // Step 6: Check if dev server is reachable (non-blocking warning) - only when using standalone proxy if (!viteProxyActive && devServerUrl) { await this.checkDevServerHealth(devServerUrl); @@ -417,12 +487,35 @@ export default class WebappDev extends SfCommand { this.log(messages.getMessage('info.ready-for-development', [finalUrl])); } // Show appropriate stop message based on execution context - // In TTY (interactive terminal): show "Press Ctrl+C to stop" - // In non-TTY (IDE, CI, piped): show generic "Server running" message + // In TTY: match the "Stopped" messages (dev server, proxy server, or both) + // In non-TTY (IDE, CI, piped): same target-based format, but "Close Live Preview" instead of Ctrl+C + const hasProxy = !!this.proxyServer; + const hasDevServer = !!this.devServerManager; + const targetKey = + hasProxy && hasDevServer + ? 'info.stop-target-both' + : hasProxy + ? 'info.stop-target-proxy' + : hasDevServer + ? 'info.stop-target-dev' + : null; + const runningTargetKey = + hasProxy && hasDevServer + ? 'info.server-running-target-both' + : hasProxy + ? 'info.server-running-target-proxy' + : hasDevServer + ? 'info.server-running-target-dev' + : null; + if (process.stdout.isTTY) { - this.log(messages.getMessage('info.press-ctrl-c')); + if (targetKey) { + this.log(messages.getMessage('info.press-ctrl-c-target', [messages.getMessage(targetKey)])); + } else { + this.log(messages.getMessage('info.press-ctrl-c')); + } } else { - this.log(messages.getMessage('info.server-running')); + this.log(messages.getMessage(runningTargetKey ?? 'info.server-running')); } this.log(''); @@ -553,13 +646,13 @@ export default class WebappDev extends SfCommand { } if (showShutdownLog) { - if (hasProxy && hasDevServer) { - this.log(messages.getMessage('info.stopped-dev-and-proxy')); - } else if (hasProxy) { - this.log(messages.getMessage('info.stopped-proxy-only')); - } else { - this.log(messages.getMessage('info.stopped-dev-only')); - } + const targetKey = + hasProxy && hasDevServer + ? 'info.stop-target-both' + : hasProxy + ? 'info.stop-target-proxy' + : 'info.stop-target-dev'; + this.log(messages.getMessage('info.stopped-target', [messages.getMessage(targetKey)])); } this.logger?.debug('Cleanup complete'); } diff --git a/src/config/manifest.ts b/src/config/manifest.ts index 0e0f06e..6506ed7 100644 --- a/src/config/manifest.ts +++ b/src/config/manifest.ts @@ -34,6 +34,8 @@ export type DevConfig = { command?: string; /** Explicit URL for the dev server */ url?: string; + /** Proxy port (default 4545 when not specified) */ + port?: number; }; /** diff --git a/src/config/types.ts b/src/config/types.ts index 13ab046..77ac97b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -38,11 +38,15 @@ export type WebAppDevResult = { export type DevServerOptions = { /** Command to run the dev server (e.g., "npm run dev", "yarn dev") */ command?: string; - /** Explicit URL override (skips auto-detection if provided) */ - explicitUrl?: string; + /** + * URL from config/default. Behavior depends on command: + * - url without command: skip spawning, use URL as-is, emit ready immediately + * - url with command: spawn process, no stdout parsing; caller verifies via polling + */ + url?: string; /** Working directory for the dev server process */ cwd?: string; - /** Timeout in milliseconds to wait for dev server to start */ + /** Timeout in milliseconds to wait for dev server to start (ignored when url+command; caller polls) */ startupTimeout?: number; }; diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index d10eb06..e0544ab 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -17,45 +17,9 @@ import { EventEmitter } from 'node:events'; import { spawn, type ChildProcess } from 'node:child_process'; import { Logger, SfError } from '@salesforce/core'; -import type { DevServerOptions } from '../config/types.js'; +import type { DevServerError, DevServerOptions } from '../config/types.js'; import { DevServerErrorParser } from '../error/DevServerErrorParser.js'; -/** - * URL detection patterns for various dev servers - * These patterns extract URLs from different dev server outputs - */ -/** - * URL detection patterns organized by dev server type - * Add new server patterns here for easy maintenance - */ - -// Vite dev server patterns -// Example: " ➜ Local: http://localhost:5173/" -const VITE_PATTERNS = [ - /➜\s*Local:\s+(https?:\/\/[^\s]+)/iu, // Unicode arrow (with colors) - />\s*Local:\s+(https?:\/\/[^\s]+)/i, // ASCII arrow fallback -]; - -// Create React App (Webpack) patterns -// Example: "On Your Network: http://192.168.1.1:3000" -const CRA_PATTERNS = [/On Your Network:\s+(https?:\/\/[^\s]+)/i, /Local:\s+(https?:\/\/[^\s]+)/i]; - -// Next.js dev server patterns -// Example: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" -const NEXTJS_PATTERNS = [/url:\s+(https?:\/\/[^\s,]+)/i, /-\s*Local:\s+(https?:\/\/[^\s]+)/i]; - -// Generic patterns for custom/unknown servers -// Example: "Server running at http://localhost:8080" -const GENERIC_PATTERNS = [ - /(?:Server|server|Running|running|started|Started).*?(https?:\/\/[^\s]+)/i, - /(https?:\/\/localhost:[0-9]+)/i, - /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):[0-9]+)/i, -]; - -// Combined patterns in priority order -// Specific patterns first, generic fallbacks last -const URL_PATTERNS = [...VITE_PATTERNS, ...CRA_PATTERNS, ...NEXTJS_PATTERNS, ...GENERIC_PATTERNS]; - /** * Default configuration values for DevServerManager */ @@ -69,7 +33,7 @@ const DEFAULT_OPTIONS = { */ type DevServerConfig = { command?: string; - explicitUrl?: string; + url?: string; cwd: string; startupTimeout: number; }; @@ -79,7 +43,7 @@ type DevServerConfig = { * * This class: * - Spawns the dev server as a child process (e.g., "npm run dev") - * - Detects the dev server URL by parsing stdout (supports Vite, CRA, Next.js, etc.) + * - When url is set: no stdout URL parsing; URL comes from config * - Monitors process health and emits lifecycle events * - Handles process cleanup and graceful shutdown * - Provides debug logging for process output (use SF_LOG_LEVEL=debug) @@ -107,10 +71,11 @@ export class DevServerManager extends EventEmitter { private process: ChildProcess | null = null; private detectedUrl: string | null = null; private startupTimer: NodeJS.Timeout | null = null; - private isReady = false; private readonly logger: Logger; private stderrBuffer: string[] = []; // Buffer to store stderr lines for error parsing + private outputBuffer: string[] = []; // Combined stdout+stderr for timeout context (many servers use stdout) private readonly maxStderrLines = 100; // Keep last 100 lines + private readonly maxOutputLines = 30; // Last N lines for timeout context /** * Creates a new DevServerManager instance @@ -142,78 +107,42 @@ export class DevServerManager extends EventEmitter { } /** - * Strips ANSI color codes from a string - * - * @param text - Text with potential ANSI codes - * @returns Clean text without ANSI codes - */ - private static stripAnsiCodes(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); - } - - /** - * Detects dev server URL from process output - * - * Attempts to match common dev server URL patterns like Vite, - * Create React App, Next.js, etc. Processes line-by-line for robustness. + * Returns the buffered output (stdout + stderr) from the dev server process. + * Useful for including failure context when the server times out or crashes. + * Many dev servers (e.g. Vite) output to stdout, so we capture both streams. * - * @param output - The output string to search for URLs - * @returns Detected URL or null if none found + * @returns Last N lines of combined output, or empty string */ - private static detectUrlFromOutput(output: string): string | null { - // Split by newlines and check each line separately - // This is more robust against chunked output - const lines = output.split('\n'); - - for (const line of lines) { - // Strip ANSI color codes first (some tools ignore FORCE_COLOR=0) - const cleanLine = DevServerManager.stripAnsiCodes(line); - const trimmedLine = cleanLine.trim(); - if (!trimmedLine) continue; - - for (const pattern of URL_PATTERNS) { - const match = trimmedLine.match(pattern); - - if (match?.[1]) { - const url = match[1].trim(); - // Normalize 0.0.0.0 to localhost for better usability - return url.replace('0.0.0.0', 'localhost'); - } - } - } - - return null; + public getLastOutput(): string { + return this.outputBuffer.slice(-15).join('\n'); } /** * Starts the dev server process * - * If an explicit URL is provided, skips process spawning and immediately + * If url is provided without command, skips process spawning and immediately * emits the ready event. Otherwise, spawns the dev server command and * monitors its output for URL detection. * - * @throws SfError if command is not provided and no explicit URL is set + * @throws SfError if command is not provided and no url is set * @throws SfError if process fails to start * @throws SfError if URL is not detected within the timeout period */ public start(): void { - // If explicit URL is provided, skip process spawning - if (this.options.explicitUrl) { - this.logger.debug(`Using explicit dev server URL: ${this.options.explicitUrl}`); - this.detectedUrl = this.options.explicitUrl; - this.isReady = true; + // If url provided without command, skip process spawning + if (this.options.url && !this.options.command) { + this.logger.debug(`Using dev server URL: ${this.options.url}`); + this.detectedUrl = this.options.url; this.emit('ready', this.detectedUrl); return; } // Validate that command is provided if (!this.options.command) { - throw new SfError( - '❌ Dev server command is required when explicit URL is not provided', - 'DevServerCommandRequired', - ['Provide a "command" in DevServerOptions', 'Or provide an "explicitUrl" to skip spawning'] - ); + throw new SfError('❌ Dev server command is required when url is not provided', 'DevServerCommandRequired', [ + 'Provide a "command" in DevServerOptions', + 'Or provide a "url" to skip spawning', + ]); } this.logger.debug(`Starting dev server with command: ${this.options.command}`); @@ -242,10 +171,12 @@ export class DevServerManager extends EventEmitter { // Setup process event handlers this.setupProcessHandlers(); - // Setup startup timeout - this.startupTimer = setTimeout(() => { - this.handleStartupTimeout(); - }, this.options.startupTimeout); + // Setup startup timeout only when not using url+command (caller verifies via polling) + if (!(this.options.url && this.options.command)) { + this.startupTimer = setTimeout(() => { + this.handleStartupTimeout(); + }, this.options.startupTimeout); + } } /** @@ -360,43 +291,16 @@ export class DevServerManager extends EventEmitter { } } + // Capture combined output for timeout/context (stdout + stderr) + this.outputBuffer.push(...lines.map((line) => `[${stream}] ${line}`)); + if (this.outputBuffer.length > this.maxOutputLines) { + this.outputBuffer = this.outputBuffer.slice(-this.maxOutputLines); + } + // Log dev server output (only visible when SF_LOG_LEVEL=debug) for (const line of lines) { this.logger.debug(`[Dev Server ${stream}] ${line}`); } - - // Try to detect URL if not yet ready - if (!this.isReady) { - const url = DevServerManager.detectUrlFromOutput(output); - if (url) { - this.handleUrlDetected(url); - } - } - } - - /** - * Handles successful URL detection - * - * Clears the startup timeout, marks server as ready, - * and emits the ready event - * - * @param url The detected URL - */ - private handleUrlDetected(url: string): void { - this.detectedUrl = url; - this.isReady = true; - - // Clear startup timeout - if (this.startupTimer) { - clearTimeout(this.startupTimer); - this.startupTimer = null; - } - - // Clear stderr buffer on successful start - this.stderrBuffer = []; - - this.logger.debug(`Dev server detected at: ${url}`); - this.emit('ready', url); } /** @@ -431,10 +335,11 @@ export class DevServerManager extends EventEmitter { this.logger.error(`Dev server error: ${parsedError.title}`); this.logger.debug(`Error type: ${parsedError.type}`); - // Convert to SfError for proper error handling - // Use just the message (not title) since title will be shown separately - // Prefix with ❌ for visual consistency with success messages (✅) - const sfError = new SfError(`❌ ${parsedError.message}`, 'DevServerError', parsedError.suggestions); + // Convert to SfError for proper error handling, attach parsed error for consumers + const sfError = new SfError(`❌ ${parsedError.message}`, 'DevServerError', parsedError.suggestions) as SfError & { + devServerError?: DevServerError; + }; + sfError.devServerError = parsedError; this.emit('error', sfError); } diff --git a/test/server/DevServerManager.test.ts b/test/server/DevServerManager.test.ts index a253b62..db5243c 100644 --- a/test/server/DevServerManager.test.ts +++ b/test/server/DevServerManager.test.ts @@ -36,10 +36,10 @@ describe('DevServerManager', () => { } }); - describe('Explicit URL Mode', () => { - it('should use explicit URL without spawning process', (done) => { + describe('URL without command (skip spawn)', () => { + it('should use url without spawning process', (done) => { manager = new DevServerManager({ - explicitUrl: 'http://localhost:5173', + url: 'http://localhost:5173', }); manager.on('ready', (url: string) => { @@ -54,9 +54,9 @@ describe('DevServerManager', () => { void manager.start(); }); - it('should emit ready event immediately with explicit URL', (done) => { + it('should emit ready event immediately with url', (done) => { manager = new DevServerManager({ - explicitUrl: 'http://localhost:3000', + url: 'http://localhost:3000', }); manager.on('ready', (url: string) => { @@ -73,7 +73,7 @@ describe('DevServerManager', () => { }); describe('Command Validation', () => { - it('should throw error if no command and no explicit URL provided', () => { + it('should throw error if no command and no url provided', () => { manager = new DevServerManager({}); try { @@ -250,17 +250,18 @@ describe('DevServerManager', () => { void manager.start(); }); - it('should handle explicit URL override priority', function (done) { - this.timeout(2000); + it('should spawn when url and command both provided (caller polls)', function (done) { + this.timeout(3000); manager = new DevServerManager({ - command: 'npm run dev', // This would normally spawn a process - explicitUrl: 'http://localhost:9999', // But explicit URL takes precedence + command: 'echo "started"', + url: 'http://localhost:9999', }); - manager.on('ready', (url: string) => { + // With url+command we spawn; no ready event (caller polls). Process exits quickly. + manager.on('exit', () => { try { - expect(url).to.equal('http://localhost:9999'); + expect(manager).to.not.be.null; done(); } catch (error) { done(error); @@ -328,7 +329,7 @@ describe('DevServerManager', () => { manager = new DevServerManager({ command: 'sleep 30', - explicitUrl: 'http://localhost:5000', + url: 'http://localhost:5000', }); void manager.start(); @@ -347,7 +348,7 @@ describe('DevServerManager', () => { manager = new DevServerManager({ command: 'echo "test"', - explicitUrl: 'http://localhost:5000', + url: 'http://localhost:5000', }); const exitPromise = new Promise<{ code: number | null; signal: string | null }>((resolve, reject) => { @@ -372,7 +373,7 @@ describe('DevServerManager', () => { manager = new DevServerManager({ command: 'echo "test output"', - explicitUrl: 'http://localhost:5000', + url: 'http://localhost:5000', }); const timeout = setTimeout(() => done(new Error('Test timeout')), 2000); @@ -395,7 +396,7 @@ describe('DevServerManager', () => { manager = new DevServerManager({ command: 'node -e "console.error(\'error output\')"', - explicitUrl: 'http://localhost:5000', + url: 'http://localhost:5000', }); const timeout = setTimeout(() => done(new Error('Test timeout')), 2000);