Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
474 changes: 392 additions & 82 deletions README.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion messages/webapp.dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 81 additions & 30 deletions src/commands/webapp/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
}
}

/**
* 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<boolean> {
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<WebAppDevResult> {
const { flags } = await this.parse(WebappDev);
Expand Down Expand Up @@ -290,7 +314,7 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
const actualDevServerUrl = await new Promise<string>((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',
Expand Down Expand Up @@ -327,51 +351,78 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
// 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
Expand Down Expand Up @@ -403,7 +454,7 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {

// Return result (never reached, but required for type safety)
return {
url: proxyUrl,
url: finalUrl,
devServerUrl: devServerUrl ?? '',
};
} catch (error) {
Expand All @@ -417,7 +468,7 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {

// 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',
Expand Down
5 changes: 0 additions & 5 deletions src/proxy/ProxyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
18 changes: 10 additions & 8 deletions src/server/DevServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
);
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading