Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ sf plugins link .
sf plugins
```

**Build when nested in another repo (e.g. monorepo):** If `yarn build` or `npm run compile` fails with workspace name conflicts, compile manually. The error page template is consumed from `@salesforce/webapp-experimental` at runtime (W-21111977); no copy step needed.

```bash
./node_modules/.bin/tsc -p . --pretty
sf plugins link .
```

See **[CODE_MAP.md](CODE_MAP.md)** for where AC1–AC4 and the iframe/postMessage flow live.

## Commands

### `sf webapp dev`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"repository": "salesforcecli/plugin-app-dev",
"scripts": {
"build": "wireit",
"build:no-lint": "wireit compile",
"clean": "sf-clean",
"clean-all": "sf-clean all",
"compile": "wireit",
Expand Down
121 changes: 119 additions & 2 deletions src/commands/webapp/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
// The webapp directory path (where the webapp lives)
const webappDir = selectedWebapp.path;

// AC2: Clean up any orphaned dev server from a previous session
// Must happen after webapp discovery so we know the correct directory for the PID file
const killedOrphan = await DevServerManager.cleanupOrphanedProcess(webappDir, this.logger);
if (killedOrphan) {
this.log('Cleaned up orphaned dev server from a previous session.');
}

this.logger.debug(`Using webapp: ${selectedWebapp.name} at ${selectedWebapp.relativePath}`);

// Step 2: Handle manifest-based vs no-manifest webapps
Expand Down Expand Up @@ -315,6 +322,109 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
this.log(messages.getMessage('info.start-dev-server-hint'));
});

// AC1+AC4: Listen for "restart dev server" requests from the interactive error page
this.proxyServer.on('restartDevServer', () => {
Comment on lines +325 to +326
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to add these event listeners to the proxy server in the CLI. The server can be managed directly in the terminal. Programmatic controls are likely only needed in the VS Code extension and should live in the extension itself.

this.logger?.info('Received restartDevServer request from error page');
const doRestart = async (): Promise<void> => {
// Stop existing dev server
if (this.devServerManager) {
this.log('Stopping current dev server for restart...');
await this.devServerManager.stop();
this.devServerManager = null;
}
// Small delay for port release
await new Promise((resolve) => setTimeout(resolve, 1000));
// Re-create and start
const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND;
this.devServerManager = new DevServerManager({
command: devCommand,
cwd: webappDir,
});
this.devServerManager.on('ready', (readyUrl: string) => {
this.logger?.debug(`Dev server restarted at: ${readyUrl}`);
this.proxyServer?.clearActiveDevServerError();
this.proxyServer?.updateDevServerUrl(readyUrl);
});
this.devServerManager.on('error', (error: SfError | DevServerError) => {
if (
'stderrLines' in error &&
Array.isArray(error.stderrLines) &&
'title' in error &&
'type' in error
) {
this.proxyServer?.setActiveDevServerError(error);
}
});
this.devServerManager.on('exit', () => {
this.logger?.debug('Restarted dev server stopped');
});
this.devServerManager.start();
this.log('Dev server restart initiated from error page.');
};
doRestart().catch((err) => {
this.logger?.error(`Failed to restart dev server: ${err instanceof Error ? err.message : String(err)}`);
});
});

// AC4: Listen for "force kill dev server" requests from the interactive error page
this.proxyServer.on('forceKillDevServer', () => {
this.logger?.info('Received forceKillDevServer request from error page');
if (this.devServerManager) {
const pid = this.devServerManager.getPid();
if (pid) {
try {
process.kill(pid, 'SIGKILL');
this.logger?.warn(`Force-killed dev server process: PID=${pid}`);
this.log(`Dev server force-killed (PID: ${pid}).`);
} catch (err) {
this.logger?.error(
`Failed to force-kill PID=${pid}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
this.devServerManager = null;
}
});

// AC1: Listen for "start dev server" requests from the interactive error page
this.proxyServer.on('startDevServer', () => {
this.logger?.info('Received startDevServer request from error page');
if (!this.devServerManager) {
const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND;
this.devServerManager = new DevServerManager({
command: devCommand,
cwd: webappDir,
});

this.devServerManager.on('ready', (readyUrl: string) => {
this.logger?.debug(`Dev server ready at: ${readyUrl}`);
this.proxyServer?.clearActiveDevServerError();
this.proxyServer?.updateDevServerUrl(readyUrl);
});

this.devServerManager.on('error', (error: SfError | DevServerError) => {
if (
'stderrLines' in error &&
Array.isArray(error.stderrLines) &&
'title' in error &&
'type' in error
) {
this.proxyServer?.setActiveDevServerError(error);
}
this.logger?.debug(`Dev server error: ${error.message}`);
});

this.devServerManager.on('exit', () => {
this.logger?.debug('Dev server stopped');
});

this.devServerManager.start();
this.log('Dev server start initiated from error page.');
} else {
this.logger?.debug('Dev server manager already exists, ignoring start request');
}
});

// Step 5: Check if dev server is reachable (non-blocking warning)
if (devServerUrl) {
await this.checkDevServerHealth(devServerUrl);
Expand Down Expand Up @@ -373,8 +483,15 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
throw error;
}

// Wrap unknown errors
const errorMessage = error instanceof Error ? error.message : String(error);
// Wrap unknown errors (include plain objects e.g. DevServerError with .message/.title)
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message)
: typeof error === 'object' && error !== null && 'title' in error
? String((error as { title?: unknown }).title)
: String(error);
throw new SfError(`Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [
'This is an unexpected error',
'Please try again',
Expand Down
126 changes: 125 additions & 1 deletion src/proxy/ProxyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export class ProxyServer extends EventEmitter {
private proxyHandler: ProxyHandler | null = null;
private orgInfo: OrgInfo | undefined;

// AC1: Proxy-only mode (skip proxying to dev server, use proxy for Salesforce API only)
private proxyOnlyMode = false;

// Constructor
public constructor(config: ProxyServerConfig) {
super();
Expand Down Expand Up @@ -347,18 +350,139 @@ export class ProxyServer extends EventEmitter {
}
}

/**
* AC1: Handle internal proxy API requests from the interactive error page.
* Returns true if the request was handled, false if it should continue to the normal flow.
*/
private async handleProxyApi(req: IncomingMessage, res: ServerResponse, url: string): Promise<boolean> {
if (!url.startsWith('/_proxy/')) {
return false;
}

const setCorsHeaders = (): void => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};

if (req.method === 'OPTIONS') {
setCorsHeaders();
res.writeHead(204);
res.end();
return true;
}

setCorsHeaders();

const sendJson = (statusCode: number, data: Record<string, unknown>): void => {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};

const readBody = (): Promise<string> =>
new Promise((resolve) => {
let body = '';
req.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
req.on('end', () => resolve(body));
});

try {
switch (url) {
case '/_proxy/status': {
sendJson(200, {
devServerStatus: this.devServerStatus,
devServerUrl: this.config.devServerUrl,
proxyOnlyMode: this.proxyOnlyMode,
proxyUrl: this.getProxyUrl(),
workspaceScript: this.workspaceScript,
activeError: this.activeDevServerError
? { title: this.activeDevServerError.title, message: this.activeDevServerError.message }
: null,
});
return true;
}

case '/_proxy/set-url': {
const body = await readBody();
const parsed = JSON.parse(body) as { url?: string };
if (!parsed.url) {
sendJson(400, { error: 'Missing "url" in request body' });
return true;
}
this.logger.info(`[Proxy API] Updating dev server URL to: ${parsed.url}`);
this.updateDevServerUrl(parsed.url);
sendJson(200, { ok: true, devServerUrl: parsed.url });
return true;
}

case '/_proxy/retry': {
this.logger.info('[Proxy API] Retrying dev server detection');
await this.checkDevServerHealth();
sendJson(200, { ok: true, devServerStatus: this.devServerStatus });
return true;
}

case '/_proxy/start-dev': {
this.logger.info('[Proxy API] Request to start dev server');
this.emit('startDevServer');
sendJson(200, { ok: true, message: 'Dev server start requested' });
return true;
}

case '/_proxy/proxy-only': {
this.proxyOnlyMode = !this.proxyOnlyMode;
this.logger.info(`[Proxy API] Proxy-only mode: ${this.proxyOnlyMode ? 'ON' : 'OFF'}`);
sendJson(200, { ok: true, proxyOnlyMode: this.proxyOnlyMode });
return true;
}

case '/_proxy/restart': {
this.logger.info('[Proxy API] Request to restart dev server');
this.emit('restartDevServer');
sendJson(200, { ok: true, message: 'Dev server restart requested' });
return true;
}

case '/_proxy/force-kill': {
this.logger.info('[Proxy API] Request to force-kill dev server');
this.emit('forceKillDevServer');
sendJson(200, { ok: true, message: 'Dev server force-kill requested' });
return true;
}

default: {
sendJson(404, { error: `Unknown proxy API endpoint: ${url}` });
return true;
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[Proxy API] Error handling ${url}: ${errorMessage}`);
sendJson(500, { error: errorMessage });
return true;
}
}

private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = req.url ?? '/';
const method = req.method ?? 'GET';
this.logger.debug(`[${method}] ${url}`);

// AC1: Handle internal proxy API requests first
if (await this.handleProxyApi(req, res, url)) {
return;
}

if (this.activeDevServerError) {
this.logger.debug('Active dev server error - serving error page');
this.serveDevServerErrorPage(this.activeDevServerError, res);
return;
}

if (this.devServerStatus === 'down' && !url.includes('/services')) {
// AC1: In proxy-only mode, skip the dev server "down" check
if (this.devServerStatus === 'down' && !this.proxyOnlyMode && !url.includes('/services')) {
this.serveErrorPage(res);
return;
}
Expand Down
Loading