Skip to content

Commit 57aac70

Browse files
feat(webapp): Live Preview dev server error panel and proxy API (W-20243732)
- Error page with Quick Actions when dev server down - Proxy API: /_proxy/status, set-url, retry, start-dev, restart, force-kill, proxy-only - resolveDevCommand: npm run dev/yarn dev via node_modules/.bin - Tests: ProxyServer, ErrorPageRenderer, resolveDevCommand, fixtures Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9379cb7 commit 57aac70

10 files changed

Lines changed: 1937 additions & 25 deletions

File tree

src/commands/webapp/dev.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
139139
// The webapp directory path (where the webapp lives)
140140
const webappDir = selectedWebapp.path;
141141

142+
// AC2: Clean up any orphaned dev server from a previous session
143+
// Must happen after webapp discovery so we know the correct directory for the PID file
144+
const killedOrphan = await DevServerManager.cleanupOrphanedProcess(webappDir, this.logger);
145+
if (killedOrphan) {
146+
this.log('Cleaned up orphaned dev server from a previous session.');
147+
}
148+
142149
this.logger.debug(`Using webapp: ${selectedWebapp.name} at ${selectedWebapp.relativePath}`);
143150

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

325+
// AC1+AC4: Listen for "restart dev server" requests from the interactive error page
326+
this.proxyServer.on('restartDevServer', () => {
327+
this.logger?.info('Received restartDevServer request from error page');
328+
const doRestart = async (): Promise<void> => {
329+
// Stop existing dev server
330+
if (this.devServerManager) {
331+
this.log('Stopping current dev server for restart...');
332+
await this.devServerManager.stop();
333+
this.devServerManager = null;
334+
}
335+
// Small delay for port release
336+
await new Promise((resolve) => setTimeout(resolve, 1000));
337+
// Re-create and start
338+
const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND;
339+
this.devServerManager = new DevServerManager({
340+
command: devCommand,
341+
cwd: webappDir,
342+
});
343+
this.devServerManager.on('ready', (readyUrl: string) => {
344+
this.logger?.debug(`Dev server restarted at: ${readyUrl}`);
345+
this.proxyServer?.clearActiveDevServerError();
346+
this.proxyServer?.updateDevServerUrl(readyUrl);
347+
});
348+
this.devServerManager.on('error', (error: SfError | DevServerError) => {
349+
if (
350+
'stderrLines' in error &&
351+
Array.isArray(error.stderrLines) &&
352+
'title' in error &&
353+
'type' in error
354+
) {
355+
this.proxyServer?.setActiveDevServerError(error);
356+
}
357+
});
358+
this.devServerManager.on('exit', () => {
359+
this.logger?.debug('Restarted dev server stopped');
360+
});
361+
this.devServerManager.start();
362+
this.log('Dev server restart initiated from error page.');
363+
};
364+
doRestart().catch((err) => {
365+
this.logger?.error(`Failed to restart dev server: ${err instanceof Error ? err.message : String(err)}`);
366+
});
367+
});
368+
369+
// AC4: Listen for "force kill dev server" requests from the interactive error page
370+
this.proxyServer.on('forceKillDevServer', () => {
371+
this.logger?.info('Received forceKillDevServer request from error page');
372+
if (this.devServerManager) {
373+
const pid = this.devServerManager.getPid();
374+
if (pid) {
375+
try {
376+
process.kill(pid, 'SIGKILL');
377+
this.logger?.warn(`Force-killed dev server process: PID=${pid}`);
378+
this.log(`Dev server force-killed (PID: ${pid}).`);
379+
} catch (err) {
380+
this.logger?.error(
381+
`Failed to force-kill PID=${pid}: ${err instanceof Error ? err.message : String(err)}`
382+
);
383+
}
384+
}
385+
this.devServerManager = null;
386+
}
387+
});
388+
389+
// AC1: Listen for "start dev server" requests from the interactive error page
390+
this.proxyServer.on('startDevServer', () => {
391+
this.logger?.info('Received startDevServer request from error page');
392+
if (!this.devServerManager) {
393+
const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND;
394+
this.devServerManager = new DevServerManager({
395+
command: devCommand,
396+
cwd: webappDir,
397+
});
398+
399+
this.devServerManager.on('ready', (readyUrl: string) => {
400+
this.logger?.debug(`Dev server ready at: ${readyUrl}`);
401+
this.proxyServer?.clearActiveDevServerError();
402+
this.proxyServer?.updateDevServerUrl(readyUrl);
403+
});
404+
405+
this.devServerManager.on('error', (error: SfError | DevServerError) => {
406+
if (
407+
'stderrLines' in error &&
408+
Array.isArray(error.stderrLines) &&
409+
'title' in error &&
410+
'type' in error
411+
) {
412+
this.proxyServer?.setActiveDevServerError(error);
413+
}
414+
this.logger?.debug(`Dev server error: ${error.message}`);
415+
});
416+
417+
this.devServerManager.on('exit', () => {
418+
this.logger?.debug('Dev server stopped');
419+
});
420+
421+
this.devServerManager.start();
422+
this.log('Dev server start initiated from error page.');
423+
} else {
424+
this.logger?.debug('Dev server manager already exists, ignoring start request');
425+
}
426+
});
427+
318428
// Step 5: Check if dev server is reachable (non-blocking warning)
319429
if (devServerUrl) {
320430
await this.checkDevServerHealth(devServerUrl);

src/proxy/ProxyServer.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export class ProxyServer extends EventEmitter {
6262
private proxyHandler: ProxyHandler | null = null;
6363
private orgInfo: OrgInfo | undefined;
6464

65+
// AC1: Proxy-only mode (skip proxying to dev server, use proxy for Salesforce API only)
66+
private proxyOnlyMode = false;
67+
6568
// Constructor
6669
public constructor(config: ProxyServerConfig) {
6770
super();
@@ -347,18 +350,139 @@ export class ProxyServer extends EventEmitter {
347350
}
348351
}
349352

353+
/**
354+
* AC1: Handle internal proxy API requests from the interactive error page.
355+
* Returns true if the request was handled, false if it should continue to the normal flow.
356+
*/
357+
private async handleProxyApi(req: IncomingMessage, res: ServerResponse, url: string): Promise<boolean> {
358+
if (!url.startsWith('/_proxy/')) {
359+
return false;
360+
}
361+
362+
const setCorsHeaders = (): void => {
363+
res.setHeader('Access-Control-Allow-Origin', '*');
364+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
365+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
366+
};
367+
368+
if (req.method === 'OPTIONS') {
369+
setCorsHeaders();
370+
res.writeHead(204);
371+
res.end();
372+
return true;
373+
}
374+
375+
setCorsHeaders();
376+
377+
const sendJson = (statusCode: number, data: Record<string, unknown>): void => {
378+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
379+
res.end(JSON.stringify(data));
380+
};
381+
382+
const readBody = (): Promise<string> =>
383+
new Promise((resolve) => {
384+
let body = '';
385+
req.on('data', (chunk: Buffer) => {
386+
body += chunk.toString();
387+
});
388+
req.on('end', () => resolve(body));
389+
});
390+
391+
try {
392+
switch (url) {
393+
case '/_proxy/status': {
394+
sendJson(200, {
395+
devServerStatus: this.devServerStatus,
396+
devServerUrl: this.config.devServerUrl,
397+
proxyOnlyMode: this.proxyOnlyMode,
398+
proxyUrl: this.getProxyUrl(),
399+
workspaceScript: this.workspaceScript,
400+
activeError: this.activeDevServerError
401+
? { title: this.activeDevServerError.title, message: this.activeDevServerError.message }
402+
: null,
403+
});
404+
return true;
405+
}
406+
407+
case '/_proxy/set-url': {
408+
const body = await readBody();
409+
const parsed = JSON.parse(body) as { url?: string };
410+
if (!parsed.url) {
411+
sendJson(400, { error: 'Missing "url" in request body' });
412+
return true;
413+
}
414+
this.logger.info(`[Proxy API] Updating dev server URL to: ${parsed.url}`);
415+
this.updateDevServerUrl(parsed.url);
416+
sendJson(200, { ok: true, devServerUrl: parsed.url });
417+
return true;
418+
}
419+
420+
case '/_proxy/retry': {
421+
this.logger.info('[Proxy API] Retrying dev server detection');
422+
await this.checkDevServerHealth();
423+
sendJson(200, { ok: true, devServerStatus: this.devServerStatus });
424+
return true;
425+
}
426+
427+
case '/_proxy/start-dev': {
428+
this.logger.info('[Proxy API] Request to start dev server');
429+
this.emit('startDevServer');
430+
sendJson(200, { ok: true, message: 'Dev server start requested' });
431+
return true;
432+
}
433+
434+
case '/_proxy/proxy-only': {
435+
this.proxyOnlyMode = !this.proxyOnlyMode;
436+
this.logger.info(`[Proxy API] Proxy-only mode: ${this.proxyOnlyMode ? 'ON' : 'OFF'}`);
437+
sendJson(200, { ok: true, proxyOnlyMode: this.proxyOnlyMode });
438+
return true;
439+
}
440+
441+
case '/_proxy/restart': {
442+
this.logger.info('[Proxy API] Request to restart dev server');
443+
this.emit('restartDevServer');
444+
sendJson(200, { ok: true, message: 'Dev server restart requested' });
445+
return true;
446+
}
447+
448+
case '/_proxy/force-kill': {
449+
this.logger.info('[Proxy API] Request to force-kill dev server');
450+
this.emit('forceKillDevServer');
451+
sendJson(200, { ok: true, message: 'Dev server force-kill requested' });
452+
return true;
453+
}
454+
455+
default: {
456+
sendJson(404, { error: `Unknown proxy API endpoint: ${url}` });
457+
return true;
458+
}
459+
}
460+
} catch (error) {
461+
const errorMessage = error instanceof Error ? error.message : String(error);
462+
this.logger.error(`[Proxy API] Error handling ${url}: ${errorMessage}`);
463+
sendJson(500, { error: errorMessage });
464+
return true;
465+
}
466+
}
467+
350468
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
351469
const url = req.url ?? '/';
352470
const method = req.method ?? 'GET';
353471
this.logger.debug(`[${method}] ${url}`);
354472

473+
// AC1: Handle internal proxy API requests first
474+
if (await this.handleProxyApi(req, res, url)) {
475+
return;
476+
}
477+
355478
if (this.activeDevServerError) {
356479
this.logger.debug('Active dev server error - serving error page');
357480
this.serveDevServerErrorPage(this.activeDevServerError, res);
358481
return;
359482
}
360483

361-
if (this.devServerStatus === 'down' && !url.includes('/services')) {
484+
// AC1: In proxy-only mode, skip the dev server "down" check
485+
if (this.devServerStatus === 'down' && !this.proxyOnlyMode && !url.includes('/services')) {
362486
this.serveErrorPage(res);
363487
return;
364488
}

0 commit comments

Comments
 (0)