diff --git a/README.md b/README.md index f1cdd12..efd03b4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![NPM](https://img.shields.io/npm/v/@salesforce/plugin-app-dev.svg?label=@salesforce/plugin-app-dev)](https://www.npmjs.com/package/@salesforce/plugin-app-dev) [![Downloads/week](https://img.shields.io/npm/dw/@salesforce/plugin-app-dev.svg)](https://npmjs.org/package/@salesforce/plugin-app-dev) [![License](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://opensource.org/license/apache-2-0) -# Salesforce CLI App Dev Plugin +# Salesforce CLI Webapp Plugin A Salesforce CLI plugin for building web applications that integrate with Salesforce. This plugin provides tools for local development, packaging, and deployment of webapps with built-in Salesforce authentication. @@ -10,14 +10,18 @@ This plugin is bundled with the [Salesforce CLI](https://developer.salesforce.co We always recommend using the latest version of these commands bundled with the CLI, however, you can install a specific version or tag if needed. -## Features +## Key Features -- 🔐 **Local Development Proxy** - Run webapps locally with automatic Salesforce authentication -- 🌐 **Intelligent Request Routing** - Automatically routes requests between Salesforce APIs and dev servers -- 🔄 **Dev Server Management** - Spawns and monitors dev servers (Vite, CRA, Next.js) -- 🎨 **Beautiful Error Handling** - HTML error pages with auto-refresh and diagnostics -- 💚 **Health Monitoring** - Periodic health checks with status updates -- 🔧 **Hot Config Reload** - Detects `webapplication.json` changes automatically +- **Auto-Discovery**: Automatically finds webapps in `webapplications/` folder +- **Optional Manifest**: `webapplication.json` is optional - uses sensible defaults +- **Auto-Selection**: Automatically selects webapp when running from inside its folder +- **Interactive Selection**: Prompts with arrow-key navigation to select webapp at project root +- **Authentication Injection**: Automatically adds Salesforce auth headers to API calls +- **Intelligent Routing**: Routes requests to dev server or Salesforce based on URL patterns +- **Hot Module Replacement**: Full HMR support for Vite, Webpack, and other bundlers +- **Manifest Hot Reload**: Edit `webapplication.json` while running - changes apply automatically +- **Health Monitoring**: Displays helpful error pages when dev server is down with auto-refresh +- **Framework Agnostic**: Works with any web framework (React, Vue, Angular, etc.) ## Quick Start @@ -72,118 +76,424 @@ We always recommend using the latest version of these commands bundled with the sf plugins install @salesforce/plugin-app-dev@x.y.z ``` -## Issues +--- -Please report any issues at https://github.com/forcedotcom/cli/issues +## Quick Start -## Contributing +### 1. Create your webapp in the SFDX project structure -1. Please read our [Code of Conduct](CODE_OF_CONDUCT.md) -2. Create a new issue before starting your project so that we can keep track of - what you are trying to add/fix. That way, we can also offer suggestions or - let you know if there is already an effort in progress. -3. Fork this repository. -4. [Build the plugin locally](#build) -5. Create a _topic_ branch in your fork. Note, this step is recommended but technically not required if contributing using a fork. -6. Edit the code in your fork. -7. Write appropriate tests for your changes. Try to achieve at least 95% code coverage on any new code. No pull request will be accepted without unit tests. -8. Sign CLA (see [CLA](#cla) below). -9. Send us a pull request when you are done. We'll review your code, suggest any needed changes, and merge it in. +``` +my-sfdx-project/ +├── sfdx-project.json +└── force-app/main/default/webapplications/ + └── my-app/ + ├── my-app.webapplication-meta.xml # Required: identifies as webapp + ├── package.json + ├── src/ + └── webapplication.json # Optional: dev configuration +``` -### CLA +### 2. Run the command -External contributors will be required to sign a Contributor's License -Agreement. You can do so by going to https://cla.salesforce.com/sign-cla. +```bash +sf webapp dev --target-org myOrg --open +``` -### Build +### 3. Start developing + +Browser opens with your app running and Salesforce authentication ready. + +- **With Vite plugin**: Open `http://localhost:5173` (Vite handles proxy) +- **Without Vite plugin**: Open `http://localhost:4545` (standalone proxy) + +> **Note**: `{name}.webapplication-meta.xml` is **required** to identify a valid webapp. The `webapplication.json` is optional - if not present, defaults to `npm run dev` command. + +--- -To build the plugin locally, make sure to have yarn installed and run the following commands: +## Commands + +### `sf webapp dev` + +Start a local development proxy server for webapp development with Salesforce authentication. ```bash -# Clone the repository -git clone git@github.com:salesforcecli/plugin-app-dev +sf webapp dev [OPTIONS] +``` + +#### Options + +| Option | Short | Description | Default | +| -------------- | ----- | ----------------------------------------------- | ------------- | +| `--target-org` | `-o` | Salesforce org alias or username | Required | +| `--name` | `-n` | Web application name (from webapplication.json) | Auto-discover | +| `--url` | `-u` | Explicit dev server URL | Auto-detect | +| `--port` | `-p` | Proxy server port | 4545 | +| `--open` | `-b` | Open browser automatically | false | + +#### Examples + +```bash +# Simplest - auto-discovers webapp +sf webapp dev --target-org myOrg + +# With browser auto-open +sf webapp dev --target-org myOrg --open + +# Specify webapp by name (when multiple exist) +sf webapp dev --name myApp --target-org myOrg + +# Custom proxy port +sf webapp dev --target-org myOrg --port 8080 + +# Connect to existing dev server (proxy-only mode) +sf webapp dev --target-org myOrg --url http://localhost:5173 + +# Debug mode +SF_LOG_LEVEL=debug sf webapp dev --target-org myOrg +``` + +--- + +## Configuration + +### webapplication.json Schema + +The `webapplication.json` file is **optional**. If not present, defaults are used. + +| Field | Type | Description | Default | +| ------------- | ------ | ----------------------------------------- | ------------- | +| `name` | string | Unique identifier (used with --name flag) | Folder name | +| `dev.command` | string | Command to start the dev server | `npm run dev` | +| `dev.url` | string | Dev server URL (skip starting server) | Auto-detect | + +#### Examples + +**No manifest (uses defaults):** +``` +webapplications/my-app/ +├── my-app.webapplication-meta.xml +├── package.json # Has "scripts": { "dev": "vite" } +└── src/ +``` + +**Custom dev command:** +```json +{ + "dev": { + "command": "npm start" + } +} +``` + +**Explicit URL (dev server already running):** +```json +{ + "dev": { + "url": "http://localhost:5173" + } +} +``` + +--- + +## Webapp Discovery + +The command discovers webapps using a deterministic algorithm. Webapps are identified by the presence of a `{name}.webapplication-meta.xml` file (SFDX metadata format). + +### Discovery Behavior + +| Scenario | Behavior | +| ----------------------------------- | --------------------------------------------------------- | +| `--name myApp` provided | Finds webapp by name, starts dev server | +| Running from inside webapp folder | Auto-selects that webapp | +| `--name` conflicts with current dir | Error: must match current webapp or run from project root | +| At SFDX project root | Prompts for webapp selection | +| Outside SFDX project with meta.xml | Uses current directory as standalone webapp | +| No webapp found | Shows error with helpful message | + +### Folder Structure -# Install the dependencies and compile -yarn && yarn build ``` +my-sfdx-project/ +├── sfdx-project.json # SFDX project marker +└── force-app/main/default/ + └── webapplications/ # Standard SFDX location + ├── app-one/ + │ ├── app-one.webapplication-meta.xml # Required + │ ├── webapplication.json # Optional + │ ├── package.json + │ └── src/ + └── app-two/ + ├── app-two.webapplication-meta.xml # Required + ├── package.json + └── src/ +``` + +### Interactive Selection + +When at the SFDX project root, you'll see an interactive prompt to select a webapp: + +``` +? Select the webapp to run: (Use arrow keys) +❯ MyApp + app-two + CustomName +``` + +--- + +## Vite Integration (Recommended) + +When using **Vite** as your bundler, the `@salesforce/vite-plugin-webapp-experimental` package provides built-in proxy functionality. + +### Setup -To use your plugin, run using the local `./bin/dev` or `./bin/dev.cmd` file. +**1. Install the Vite plugin** ```bash -# Run using local run file. -./bin/dev hello world +npm install -D @salesforce/vite-plugin-webapp-experimental ``` -There should be no differences when running via the Salesforce CLI or using the local run file. However, it can be useful to link the plugin to do some additional testing or run your commands from anywhere on your machine. +**2. Configure vite.config.ts** + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import salesforce from '@salesforce/vite-plugin-webapp-experimental'; + +export default defineConfig({ + plugins: [ + react(), + salesforce() // No configuration needed + ], +}); +``` + +**3. Run the dev command** ```bash -# Link your plugin to the sf cli -sf plugins link . -# To verify -sf plugins +sf webapp dev --target-org myOrg ``` -## Commands +### How It Works -### `sf webapp dev` +The CLI automatically detects whether your Vite dev server has the Salesforce plugin by sending a health check request. If the plugin responds with `X-Salesforce-WebApp-Proxy: true`, the CLI skips starting its standalone proxy. -Start a local development proxy server for webapp development with Salesforce authentication. +| Scenario | Proxy Behavior | +|-----------------------------|------------------------------------------------| +| Vite plugin **present** | Uses Vite's built-in proxy (open `:5173`) | +| Vite plugin **not present** | CLI creates standalone proxy (open `:4545`) | + +### Benefits + +| Feature | Vite Plugin | Standalone Proxy | +|-------------------------|--------------------|---------------------------| +| Single port to access | ✅ (5173) | ❌ (proxy 4545, dev 5173) | +| Simpler browser URL | ✅ `localhost:5173`| `localhost:4545` | +| HMR through same port | ✅ Native | ✅ Forwarded | + +--- + +## The `--url` Flag + +The `--url` flag provides control over which dev server URL the proxy uses. + +| 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 | + +### Example: Connect to Existing Dev Server + +```bash +# Terminal 1: Start your dev server manually +npm run dev +# Output: Local: http://localhost:5173/ + +# Terminal 2: Connect proxy to your running server +sf webapp dev --url http://localhost:5173 --target-org myOrg +``` + +--- + +## Troubleshooting + +### "No webapp found" or "No valid webapps" + +Ensure your webapp has the required `.webapplication-meta.xml` file: + +``` +webapplications/my-app/ +├── my-app.webapplication-meta.xml # Required! +├── package.json +└── webapplication.json # Optional +``` + +### "You are inside webapp X but specified --name Y" + +**Solutions:** +- Remove `--name` to use the current webapp +- Navigate to the project root and use `--name` + +### "Dependencies Not Installed" / "command not found" + +```bash +cd webapplications/my-app +npm install +``` + +### "Port 4545 already in use" ```bash -USAGE - $ sf webapp dev --name --target-org [options] +sf webapp dev --port 8080 --target-org myOrg +``` -REQUIRED FLAGS - -n, --name= Name of the webapp (must match webapplication.json) - -o, --target-org= Salesforce org to authenticate against +### "Authentication Failed" -OPTIONAL FLAGS - -u, --url= Dev server URL (overrides webapplication.json) - -p, --port= Proxy server port (default: 4545) - --open Open browser automatically +```bash +sf org login web --alias myOrg +``` -GLOBAL FLAGS - --flags-dir= Import flag values from a directory - --json Format output as json +### Debug Mode -DESCRIPTION - Start a local development proxy server for webapp development. +```bash +# Terminal 1: Tail logs +tail -f ~/.sf/sf-$(date +%Y-%m-%d).log | grep --line-buffered WebappDev - 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. +# Terminal 2: Run with debug +SF_LOG_LEVEL=debug sf webapp dev --target-org myOrg +``` -EXAMPLES - Start proxy with automatic dev server management: +--- + +## Architecture + +### Request Flow + +The command supports two proxy modes: + +**With Vite Plugin:** +``` +Browser → Vite Dev Server (:5173) → Salesforce (with auth) + ↓ + Proxy handles: + • /services/* → Salesforce + • Everything else → Vite HMR +``` + +**Standalone Proxy:** +``` +Browser → Proxy Server (:4545) → Salesforce (with auth) + ↓ + Dev Server (:5173) for static assets +``` - $ sf webapp dev --name myapp --target-org myorg --open +### Request Routing - Use existing dev server: +| URL Path | Routed To | +|-----------------------------|---------------------| +| `/services/*`, `/lwr/apex/*`| Salesforce (+ auth) | +| Everything else | Dev Server | - $ sf webapp dev --name myapp --target-org myorg --url http://localhost:5173 --open +--- - Use custom proxy port: +## VSCode Integration - $ sf webapp dev --name myapp --target-org myorg --port 8080 --open +The command integrates with the Salesforce VSCode UI Preview extension (`salesforcedx-vscode-ui-preview`): -SUPPORTED DEV SERVERS - - Vite - - Create React App (Webpack) - - Next.js - - Any server that outputs http://localhost:PORT +1. Extension detects `webapplication.json` in workspace +2. User clicks "Preview" button +3. Extension executes: `sf webapp dev --target-org --open` +4. Browser opens with the app running -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 +## JSON Output + +For scripting and CI/CD: + +```bash +sf webapp dev --target-org myOrg --json +``` + +```json +{ + "status": 0, + "result": { + "url": "http://localhost:4545", + "devServerUrl": "http://localhost:5173" + } +} +``` + +--- + +## Issues + +Please report any issues at https://github.com/forcedotcom/cli/issues + +## Contributing + +1. Please read our [Code of Conduct](CODE_OF_CONDUCT.md) +2. Create a new issue before starting your project so that we can keep track of what you are trying to add/fix. +3. Fork this repository. +4. [Build the plugin locally](#build) +5. Create a _topic_ branch in your fork. +6. Edit the code in your fork. +7. Write appropriate tests for your changes. Try to achieve at least 95% code coverage on any new code. +8. Sign CLA (see [CLA](#cla) below). +9. Send us a pull request when you are done. + +### CLA + +External contributors will be required to sign a Contributor's License Agreement. You can do so by going to https://cla.salesforce.com/sign-cla. + +### Build + +```bash +# Clone the repository +git clone git@github.com:salesforcecli/plugin-webapp + +# Install dependencies and compile +yarn && yarn build + +# Run using local dev file +./bin/dev webapp dev --target-org myOrg + +# Link to SF CLI for testing +sf plugins link . +sf plugins # Verify + +# After code changes, just rebuild +yarn build +``` + +### Project Structure + +``` +plugin-webapp/ +├── src/ +│ ├── commands/webapp/ +│ │ └── dev.ts # Main command implementation +│ ├── config/ +│ │ ├── manifest.ts # Manifest type definitions +│ │ ├── ManifestWatcher.ts # File watching and hot reload +│ │ ├── webappDiscovery.ts # Auto-discovery logic +│ │ └── types.ts # Shared TypeScript types +│ ├── proxy/ +│ │ └── ProxyServer.ts # HTTP/WebSocket proxy server +│ ├── server/ +│ │ └── DevServerManager.ts # Dev server process management +│ ├── error/ +│ │ └── DevServerErrorParser.ts # Parse dev server errors +│ └── templates/ +│ ├── ErrorPageRenderer.ts # Browser error page generation +│ └── error-page.html # Error page HTML template +├── messages/ +│ └── webapp.dev.md # CLI messages and help text +└── schemas/ + └── webapp-dev.json # JSON schema for output ``` diff --git a/messages/webapp.dev.md b/messages/webapp.dev.md index 1e70110..a27d832 100644 --- a/messages/webapp.dev.md +++ b/messages/webapp.dev.md @@ -82,17 +82,26 @@ Dev server URL: %s # info.proxy-url -Proxy URL: %s (open this in your browser) +Proxy URL: %s (open this URL in your browser) # info.ready-for-development ✅ Ready for development! → %s (open this URL in your browser) +# info.ready-for-development-vite + +✅ Ready for development! + → %s (Vite proxy active - open this URL in your browser) + # info.press-ctrl-c Press Ctrl+C to stop the proxy server +# info.server-running + +Dev server is running. Stop it by running "SFDX: Close Live Preview" from the VS Code command palette. + # info.dev-server-healthy ✓ Dev server is responding at: %s @@ -178,3 +187,7 @@ Using default dev command: %s ⚠️ The --url flag (%s) does not match the actual dev server URL (%s). The proxy will use the actual dev server URL. + +# info.vite-proxy-detected + +Vite WebApp proxy detected at %s - using Vite's built-in proxy (standalone proxy skipped) diff --git a/package.json b/package.json index 9185089..292b35f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@oclif/plugin-command-snapshot": "^5.3.8", "@salesforce/cli-plugins-testkit": "^5.3.41", "@salesforce/dev-scripts": "^11.0.4", - "@salesforce/plugin-command-reference": "^3.1.79", + "@salesforce/plugin-command-reference": "^3.1.77", "@types/http-proxy": "^1.17.14", "@types/micromatch": "^4.0.10", "eslint-plugin-sf-plugin": "^1.20.33", diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index a6f784d..0959e4f 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -118,6 +118,30 @@ export default class WebappDev extends SfCommand { } } + /** + * 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 + * when the proxy middleware is active. + * + * @param devServerUrl - The dev server URL to check + * @returns true if Vite's proxy is handling requests, false otherwise + */ + private static async checkViteProxyActive(devServerUrl: string): Promise { + try { + // The Vite plugin uses a query parameter for health checks, not a path + const healthUrl = new URL(devServerUrl); + healthUrl.searchParams.set('sfProxyHealthCheck', 'true'); + const response = await fetch(healthUrl.toString(), { + method: 'GET', + signal: AbortSignal.timeout(3000), // 3 second timeout + }); + return response.headers.get('X-Salesforce-WebApp-Proxy') === 'true'; + } catch { + // Health check failed - Vite proxy not active + return false; + } + } + // eslint-disable-next-line complexity public async run(): Promise { const { flags } = await this.parse(WebappDev); @@ -290,7 +314,7 @@ export default class WebappDev extends SfCommand { const actualDevServerUrl = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject( - new SfError('Dev server did not start within 30 seconds.', 'DevServerTimeoutError', [ + new SfError('❌ Dev server did not start within 30 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', @@ -327,51 +351,78 @@ export default class WebappDev extends SfCommand { // Ensure devServerUrl is set (should always be set by step 3) if (!devServerUrl) { throw new SfError( - 'Unable to determine dev server URL. Please specify --url or configure dev.url in webapplication.json.', + '❌ Unable to determine dev server URL. Please specify --url or configure dev.url in webapplication.json.', 'DevServerUrlError' ); } - // Step 5: Start 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, - }); + // Step 5: Check for Vite proxy and conditionally start standalone proxy + this.logger.debug('Checking if Vite WebApp proxy is active...'); + const viteProxyActive = await WebappDev.checkViteProxyActive(devServerUrl); - await this.proxyServer.start(); - const proxyUrl = this.proxyServer.getProxyUrl(); - this.logger.debug(`Proxy server running on ${proxyUrl}`); + // Track the final URL to open in browser (either proxy or dev server) + let finalUrl: string; - // Listen for dev server status changes (minimal output) - this.proxyServer.on('dev-server-up', (url: string) => { - this.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); - }); + if (viteProxyActive) { + // Vite's WebAppProxyHandler is handling the proxy - skip standalone proxy + this.log(messages.getMessage('info.vite-proxy-detected', [devServerUrl])); + 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, + }); - 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')); - }); + await this.proxyServer.start(); + 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.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); + }); + + 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')); + }); - // Step 6: Check if dev server is reachable (non-blocking warning) - if (devServerUrl) { + finalUrl = proxyUrl; + } + + // Step 6: Check if dev server is reachable (non-blocking warning) - only when using standalone proxy + if (!viteProxyActive && devServerUrl) { await this.checkDevServerHealth(devServerUrl); } // Step 7: Open browser if requested if (flags.open) { this.logger.debug('Opening browser...'); - await WebappDev.openBrowser(proxyUrl); + await WebappDev.openBrowser(finalUrl); } // Display usage instructions this.log(''); - this.log(messages.getMessage('info.ready-for-development', [proxyUrl])); - this.log(messages.getMessage('info.press-ctrl-c')); + if (viteProxyActive) { + this.log(messages.getMessage('info.ready-for-development-vite', [devServerUrl])); + } else { + 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 + if (process.stdout.isTTY) { + this.log(messages.getMessage('info.press-ctrl-c')); + } else { + this.log(messages.getMessage('info.server-running')); + } this.log(''); // Keep the command running until interrupted or dev server exits @@ -403,7 +454,7 @@ export default class WebappDev extends SfCommand { // Return result (never reached, but required for type safety) return { - url: proxyUrl, + url: finalUrl, devServerUrl: devServerUrl ?? '', }; } catch (error) { @@ -417,7 +468,7 @@ export default class WebappDev extends SfCommand { // Wrap unknown errors const errorMessage = error instanceof Error ? error.message : String(error); - throw new SfError(`Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [ + throw new SfError(`❌ Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [ 'This is an unexpected error', 'Please try again', 'If the problem persists, check the command logs with SF_LOG_LEVEL=debug', diff --git a/src/proxy/ProxyServer.ts b/src/proxy/ProxyServer.ts index 62d0af1..8e3f977 100644 --- a/src/proxy/ProxyServer.ts +++ b/src/proxy/ProxyServer.ts @@ -176,11 +176,6 @@ export class ProxyServer extends EventEmitter { this.server.on('connection', (socket) => { this.activeConnections.add(socket); - socket.on('error', (err) => { - // Handle ECONNRESET and other socket errors gracefully - // These can happen when the dev server crashes or a client disconnects abruptly - this.logger.debug(`Socket error (${err.message}), cleaning up connection`); - }); socket.once('close', () => { this.activeConnections.delete(socket); }); diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index fa6f16b..6475d0d 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -210,7 +210,7 @@ export class DevServerManager extends EventEmitter { // Validate that command is provided if (!this.options.command) { throw new SfError( - 'Dev server command is required when explicit URL is not provided', + '❌ Dev server command is required when explicit URL is not provided', 'DevServerCommandRequired', ['Provide a "command" in DevServerOptions', 'Or provide an "explicitUrl" to skip spawning'] ); @@ -232,7 +232,7 @@ export class DevServerManager extends EventEmitter { } catch (error) { const sfError = error instanceof Error ? error : new Error(error instanceof Object ? JSON.stringify(error) : String(error)); - throw new SfError(`Failed to spawn dev server process: ${sfError.message}`, 'DevServerSpawnError', [ + throw new SfError(`❌ Failed to spawn dev server process: ${sfError.message}`, 'DevServerSpawnError', [ `Verify the command is correct: ${this.options.command}`, 'Check that the executable exists in your PATH', 'Ensure you have the necessary dependencies installed', @@ -430,10 +430,12 @@ export class DevServerManager extends EventEmitter { this.logger.error(`Dev server error: ${parsedError.title}`); this.logger.debug(`Error type: ${parsedError.type}`); - // Emit the parsed DevServerError directly so the receiver (dev.ts) - // can access stderrLines, title, and type for the error page. - // Previously this was wrapped in SfError which lost those properties. - this.emit('error', parsedError); + // 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); + + this.emit('error', sfError); } // Reset state @@ -450,7 +452,7 @@ export class DevServerManager extends EventEmitter { private handleProcessError(error: Error): void { this.logger.error(`Dev server process error: ${error.message}`); - const sfError = new SfError(`Dev server process error: ${error.message}`, 'DevServerProcessError', [ + const sfError = new SfError(`❌ Dev server process error: ${error.message}`, 'DevServerProcessError', [ 'Check that the command is correct in webapplication.json', 'Verify all dependencies are installed', 'Try running the command manually to see the error', @@ -469,7 +471,7 @@ export class DevServerManager extends EventEmitter { this.logger.error('Dev server failed to start within timeout period'); const error = new SfError( - `Dev server did not start within ${this.options.startupTimeout / 1000} seconds`, + `❌ Dev server did not start within ${this.options.startupTimeout / 1000} seconds`, 'DevServerStartupTimeout', [ 'The dev server may be taking longer than expected to start', diff --git a/test/commands/webapp/dev.test.ts b/test/commands/webapp/dev.test.ts index 3a0fcbd..161b7f5 100644 --- a/test/commands/webapp/dev.test.ts +++ b/test/commands/webapp/dev.test.ts @@ -15,6 +15,7 @@ */ import { expect } from 'chai'; +import sinon from 'sinon'; import { TestContext } from '@salesforce/core/testSetup'; import type { WebAppManifest, WebAppDevResult } from '../../../src/config/types.js'; @@ -25,6 +26,151 @@ describe('webapp:dev command integration', () => { $$.restore(); }); + describe('Vite Proxy Detection', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(global, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + /** + * Helper function that mirrors the checkViteProxyActive logic from dev.ts + * This allows us to test the detection behavior without needing to run the full command + */ + async function checkViteProxyActive(devServerUrl: string): Promise { + try { + const healthUrl = new URL(devServerUrl); + healthUrl.searchParams.set('sfProxyHealthCheck', 'true'); + const response = await fetch(healthUrl.toString(), { + method: 'GET', + signal: AbortSignal.timeout(3000), + }); + return response.headers.get('X-Salesforce-WebApp-Proxy') === 'true'; + } catch { + return false; + } + } + + it('should return true when X-Salesforce-WebApp-Proxy header is present and true', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.true; + expect(fetchStub.calledOnce).to.be.true; + + // Verify the correct URL with query parameter was called + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.include('sfProxyHealthCheck=true'); + }); + + it('should return false when X-Salesforce-WebApp-Proxy header is not present', async () => { + const mockHeaders = new Headers(); + // No X-Salesforce-WebApp-Proxy header + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when X-Salesforce-WebApp-Proxy header is present but not "true"', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'false'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when fetch throws an error (network failure)', async () => { + fetchStub.rejects(new Error('Network error')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when fetch times out', async () => { + fetchStub.rejects(new DOMException('The operation was aborted', 'AbortError')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when dev server is not reachable (connection refused)', async () => { + fetchStub.rejects(new TypeError('Failed to fetch')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should construct correct health check URL with query parameter', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173'); + + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.equal('http://localhost:5173/?sfProxyHealthCheck=true'); + }); + + it('should preserve existing query parameters when adding health check', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173/?existing=param'); + + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.include('existing=param'); + expect(calledUrl).to.include('sfProxyHealthCheck=true'); + }); + + it('should use GET method for health check request', async () => { + const mockHeaders = new Headers(); + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173'); + + const options = fetchStub.firstCall.args[1] as RequestInit; + expect(options.method).to.equal('GET'); + }); + }); + describe('Type Definitions', () => { it('should have correct WebAppManifest structure', () => { const manifest: WebAppManifest = { diff --git a/test/config/ManifestWatcher.test.ts b/test/config/ManifestWatcher.test.ts index 16d35a3..54d72a2 100644 --- a/test/config/ManifestWatcher.test.ts +++ b/test/config/ManifestWatcher.test.ts @@ -142,7 +142,7 @@ describe('ManifestWatcher', () => { }); describe('Partial Manifest Support', () => { - it('should accept manifest with only dev.command (no name, outputDir)', async () => { + it('should accept manifest with only dev.command (no name, label, version, outputDir)', async () => { const partialManifest = { dev: { command: 'npm run dev', @@ -271,13 +271,13 @@ describe('ManifestWatcher', () => { // Wait a bit then modify the file setTimeout(() => { - const updated = { ...validManifest, name: 'updatedApp' }; + const updated = { ...validManifest, version: '2.0.0' }; writeFileSync(testManifestPath, JSON.stringify(updated, null, 2)); }, 200); const event = await changePromise; expect(event.type).to.equal('changed'); - expect(event.manifest?.name).to.equal('updatedApp'); + expect((event.manifest as unknown as Record)?.version).to.equal('2.0.0'); await watcher.stop(); }); @@ -348,22 +348,22 @@ describe('ManifestWatcher', () => { // Make multiple rapid changes setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change1' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.1' }, null, 2)); }, 100); setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change2' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.2' }, null, 2)); }, 150); setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change3' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.3' }, null, 2)); }, 200); // Check that only one change event was emitted after debounce await new Promise((resolve) => setTimeout(resolve, 800)); expect(changeCount).to.equal(1); - expect(watcher.getManifest()?.name).to.equal('change3'); + expect((watcher.getManifest() as unknown as Record)?.version).to.equal('1.0.3'); await watcher.stop(); }); }); @@ -418,12 +418,13 @@ describe('ManifestWatcher', () => { eventEmitted = true; }); - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'changedApp' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '2.0.0' }, null, 2)); await new Promise((resolve) => setTimeout(resolve, 300)); expect(eventEmitted).to.be.false; - expect(watcher.getManifest()?.name).to.equal('testApp'); // Still old value + // Watcher stopped before write, so manifest unchanged (validManifest has no version) + expect(watcher.getManifest()).to.deep.equal(validManifest); await watcher.stop(); }); diff --git a/yarn.lock b/yarn.lock index dc543d4..8a816ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1739,10 +1739,10 @@ dependencies: "@salesforce/ts-types" "^2.0.12" -"@salesforce/plugin-command-reference@^3.1.79": - version "3.1.79" - resolved "https://registry.yarnpkg.com/@salesforce/plugin-command-reference/-/plugin-command-reference-3.1.79.tgz#36f1ea069d134ee45489882e28cc9a9bf6db6709" - integrity sha512-t3DH+Ez2ESrY8M8zO6yEodsFq7IYKBseFFRwqFxTf0bIVZcWh4i7UO8oUFQyT9Px+Q3TcnSXw4X9njopxTc2lQ== +"@salesforce/plugin-command-reference@^3.1.77": + version "3.1.78" + resolved "https://registry.npmjs.org/@salesforce/plugin-command-reference/-/plugin-command-reference-3.1.78.tgz" + integrity sha512-8GhAUhYTDD51pAFN6h/wrhD46ZJEs53EMDa//jNIe06nFxs/A2iheJSaVNyf9p+ft1KGTvhg+5WXQBFW5daFsA== dependencies: "@oclif/core" "^4" "@salesforce/core" "^8.23.3" @@ -1758,18 +1758,18 @@ resolved "https://registry.npmjs.org/@salesforce/prettier-config/-/prettier-config-0.0.3.tgz" integrity sha512-hYOhoPTCSYMDYn+U1rlEk16PoBeAJPkrdg4/UtAzupM1mRRJOwEPMG1d7U8DxJFKuXW3DMEYWr2MwAIBDaHmFg== -"@salesforce/sdk-core@^1.22.0": - version "1.22.0" - resolved "https://registry.yarnpkg.com/@salesforce/sdk-core/-/sdk-core-1.22.0.tgz#6488c2a64954ef554253f7d6293239d3e3ba9e61" - integrity sha512-L3GT267pg8iRJFXLUg+DVjn76UgJSwexXhWsAV5WDiLEkXlEwKdGFmpmKYbDx9M9sUN3NckiYw+trWGRjUEHNw== +"@salesforce/sdk-core@^1.29.1": + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/sdk-core/-/sdk-core-1.29.1.tgz#4679e9e2cc8c34fafb302610312f1f8eb249349e" + integrity sha512-Xc2Hh1yzV+vMj8+Ot4SjBIJgqU8OZJ51H3Hg6clKlzlzcuBzSTprHB4Cw252NWOGJ7UtG0rLGG8Q6F3qEg2SMQ== -"@salesforce/sdk-data@^1.22.0": - version "1.22.0" - resolved "https://registry.yarnpkg.com/@salesforce/sdk-data/-/sdk-data-1.22.0.tgz#2dbf26f8b29f4bcc56aaf070baa74dbe64d02cd6" - integrity sha512-KH5RcQfyXj0jjvpI7gv54+e7qhiOBZ+XjuBA6UsOuk4bRvRfvqtwxCl1qSTLTU6iEoburudq6ixu1n7A6MOG+g== +"@salesforce/sdk-data@^1.29.1": + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/sdk-data/-/sdk-data-1.29.1.tgz#4a1ad906d597d9e0ffca4e8c2991e2e54b90db7d" + integrity sha512-8TjrB8GiEgMEnVveQahYFd+8ak5Dr5xJgj0DoIDgIkToc1t6UzPVZAP4D/XO+aydPnzYO1ZXSPbemYOa3ar+uA== dependencies: "@conduit-client/salesforce-lightning-service-worker" "^3.7.0" - "@salesforce/sdk-core" "^1.22.0" + "@salesforce/sdk-core" "^1.29.1" "@salesforce/sf-plugins-core@^11.3.12": version "11.3.12" @@ -1811,12 +1811,12 @@ integrity sha512-BIJyduJC18Kc8z+arUm5AZ9VkPRyw1KKAm+Tk+9LT99eOzhNilyfKzhZ4t+tG2lIGgnJpmytZfVDZ0e2kFul8g== "@salesforce/webapp-experimental@^1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@salesforce/webapp-experimental/-/webapp-experimental-1.23.0.tgz#b95ebfebd3254361732e8edcfbdc56a8b819a948" - integrity sha512-5EKzZ6MFnCzmKdHSSt+28riAIgFQ+5PfPRQg3Gl0mnUA3GzN9XwzEq8hI/r52sDlKPvtb2x+MKAxyYs/182OEg== + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/webapp-experimental/-/webapp-experimental-1.29.1.tgz#98648cc166b34c1b479f9545e87d10b61dd76adc" + integrity sha512-i5ZzGs7hrjv3Fbrvmm7v8OTVoJRewWvqzXIB5JRW5l2mcz1HxK2DXriBbLHHOmYRcVSrNdN/VS8PW7YwcTuHZw== dependencies: "@salesforce/core" "^8.23.4" - "@salesforce/sdk-data" "^1.22.0" + "@salesforce/sdk-data" "^1.29.1" axios "^1.7.7" micromatch "^4.0.8" path-to-regexp "^8.3.0"