From e199dc73e1fe9434fe58c92780f84626835bac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 12 May 2026 18:29:38 +0200 Subject: [PATCH 1/7] Surface local proxy errors during app dev Makes the local reverse proxy's failure modes visible instead of silent: - Wrap server.listen in a real promise so a failed bind (EADDRINUSE, etc.) rejects through the dev runner instead of crashing Node via an uncaught 'error' event. server.listen returns the Server synchronously, so the previous await was a no-op. - Move the 'Proxy server started' log to after listen actually resolves so it stops printing on failed binds. - Add a runtime server.on('error') handler that warns through the proxy stream instead of crashing the process. - Warn (not just outputDebug) when an HTTP request has no matching rule and the proxy 500s the caller. - Warn before socket.destroy() when a websocket upgrade has no matching rule, instead of dropping it silently. --- .changeset/proxy-error-visibility.md | 5 ++ .../dev/processes/setup-dev-processes.test.ts | 45 +++++++++++++++++ .../dev/processes/setup-dev-processes.ts | 22 +++++++- .../utilities/app/http-reverse-proxy.test.ts | 50 +++++++++++++++++++ .../cli/utilities/app/http-reverse-proxy.ts | 14 ++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 .changeset/proxy-error-visibility.md diff --git a/.changeset/proxy-error-visibility.md b/.changeset/proxy-error-visibility.md new file mode 100644 index 00000000000..b0f94cabee7 --- /dev/null +++ b/.changeset/proxy-error-visibility.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Surface local proxy errors during `app dev` instead of silently 500ing on unmatched paths, destroying unmatched websocket upgrades, or crashing the process if the proxy fails to bind. diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index aeb6702a4ea..aa797ada007 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -32,6 +32,7 @@ import {DeveloperPlatformClient} from '../../../utilities/developer-platform-cli import {AppEventWatcher} from '../app-events/app-event-watcher.js' import * as loader from '../../../models/app/loader.js' import {describe, test, expect, beforeEach, vi} from 'vitest' +import {AbortController} from '@shopify/cli-kit/node/abort' import {ensureAuthenticatedAdmin, ensureAuthenticatedStorefront} from '@shopify/cli-kit/node/session' import {Config} from '@oclif/core' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' @@ -39,6 +40,9 @@ import {isStorefrontPasswordProtected} from '@shopify/theme' import {fetchTheme} from '@shopify/cli-kit/node/themes/api' import {firstPartyDev} from '@shopify/cli-kit/node/context/local' import {adminFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {Writable} from 'stream' +import net from 'net' +import http from 'http' vi.mock('../../context/identifiers.js') vi.mock('@shopify/cli-kit/node/session.js') @@ -737,3 +741,44 @@ describe('setup-dev-processes', () => { }) }) }) + +describe('startProxyServer', () => { + const sinkStream = () => + new Writable({ + write(_chunk, _encoding, next) { + next() + }, + }) + + test('rejects with the bind error when the port is already in use', async () => { + // Hold a port so the proxy bind fails with EADDRINUSE. + const blocker = http.createServer() + await new Promise((resolve) => blocker.listen(0, 'localhost', resolve)) + const port = (blocker.address() as net.AddressInfo).port + + const abortController = new AbortController() + try { + await expect( + startProxyServer( + {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, + {port, rules: {default: 'http://localhost:1'}}, + ), + ).rejects.toThrow(/EADDRINUSE/) + } finally { + abortController.abort() + await new Promise((resolve) => blocker.close(() => resolve())) + } + }) + + test('resolves once the server is actually listening', async () => { + const abortController = new AbortController() + try { + await startProxyServer( + {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, + {port: 0, rules: {default: 'http://localhost:1'}}, + ) + } finally { + abortController.abort() + } + }) +}) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 0c6ca0b2f6d..cf9f5edc166 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -295,9 +295,27 @@ export const startProxyServer: DevProcessFunction<{ localhostCert?: LocalhostCert }> = async ({abortSignal, stdout}, {port, rules, localhostCert}) => { const {server} = await getProxyingWebServer(rules, abortSignal, localhostCert, stdout) + + // `server.listen` is event-based and returns the Server synchronously, so awaiting it + // does not actually wait for the socket to bind. Wrap it in a promise that resolves on + // 'listening' and rejects on 'error' (e.g. EADDRINUSE) so a failed bind surfaces to the + // dev runner instead of crashing Node via an uncaught 'error' event. + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off('listening', onListening) + reject(err) + } + const onListening = () => { + server.off('error', onError) + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, 'localhost') + }) + outputInfo( - `Proxy server started on port ${port} ${localhostCert ? `with certificate ${localhostCert.certPath}` : ''}`, + `Proxy server started on port ${port}${localhostCert ? ` with certificate ${localhostCert.certPath}` : ''}`, stdout, ) - await server.listen(port, 'localhost') } diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts index 5b7f9a64db5..5b111fddd78 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts @@ -69,6 +69,56 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => { }) }) +describe.sequential('http-reverse-proxy without a default rule', () => { + test('returns 500 for unmatched HTTP paths', {retry: 2}, async () => { + const abortController = new AbortController() + const targetServer = http.createServer((_req, res) => { + res.writeHead(200) + res.end('ok') + }) + await new Promise((resolve) => targetServer.listen(0, 'localhost', resolve)) + const targetPort = (targetServer.address() as net.AddressInfo).port + + const {server} = await getProxyingWebServer({'/known': `http://localhost:${targetPort}`}, abortController.signal) + await new Promise((resolve) => server.listen(0, 'localhost', resolve)) + const proxyPort = (server.address() as net.AddressInfo).port + + try { + const response = await fetch(`http://localhost:${proxyPort}/unknown`, { + agent: new http.Agent({keepAlive: false}), + }) + expect(response.status).toBe(500) + await expect(response.text()).resolves.toContain('Invalid path') + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + targetServer.closeAllConnections() + await new Promise((resolve) => targetServer.close(() => resolve())) + } + }) + + test('destroys websocket connections that do not match any rule', {retry: 2}, async () => { + const abortController = new AbortController() + const {server} = await getProxyingWebServer({'/known': 'http://localhost:1'}, abortController.signal) + await new Promise((resolve) => server.listen(0, 'localhost', resolve)) + const proxyPort = (server.address() as net.AddressInfo).port + + try { + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${proxyPort}/unmatched`, { + agent: new http.Agent({keepAlive: false}), + }) + ws.on('open', () => reject(new Error('connection should not have opened'))) + ws.on('error', () => resolve()) + ws.on('unexpected-response', () => resolve()) + }) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + }) +}) + function getTestReverseProxy(protocol: 'http' | 'https') { return test.extend<{ setup: { diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index 9304bab8e84..d5aee851c03 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -34,6 +34,14 @@ export async function getProxyingWebServer( // Capture websocket requests and forward them to the proxy server.on('upgrade', getProxyServerWebsocketUpgradeListener(rules, proxy, stdout)) + // Forward runtime errors from the underlying server (post-listen) to the proxy + // output instead of letting them bubble up as uncaught exceptions. + server.on('error', (err) => { + useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { + outputWarn(`Proxy server error: ${err.message}`, stdout) + }) + }) + abortSignal.addEventListener('abort', () => { outputDebug('Closing reverse HTTP proxy') server.close() @@ -58,6 +66,9 @@ function getProxyServerWebsocketUpgradeListener( }) }) } + useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { + outputWarn(`No matching websocket rule for "${req.url ?? ''}", closing connection`, stdout) + }) socket.destroy() } } @@ -80,6 +91,9 @@ function getProxyServerRequestListener( }) } + useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { + outputWarn(`No matching rule for "${req.url ?? ''}", returning 500`, stdout) + }) outputDebug(outputContent` Reverse HTTP proxy error - Invalid path: ${req.url ?? ''} These are the allowed paths: From 0257998d16e2c8e20dbf3dea78ed115cc4dd5326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 10:11:33 +0200 Subject: [PATCH 2/7] Tear down dev session on fatal proxy error The post-bind server.on('error') handler previously warned once and returned, leaving a dead proxy server while the dev session kept running. Subsequent requests would fail with ECONNREFUSED at the client with no further indication from the CLI. Keep startProxyServer running for the lifetime of the dev session: resolve on the abort signal, reject when the server emits a runtime error so ConcurrentOutput tears the whole session down instead of leaving the user with one warning and a silently broken proxy. --- .changeset/proxy-error-visibility.md | 2 +- .../dev/processes/setup-dev-processes.test.ts | 25 ++++++++++------- .../dev/processes/setup-dev-processes.ts | 27 ++++++++++++++++++- .../cli/utilities/app/http-reverse-proxy.ts | 8 ------ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.changeset/proxy-error-visibility.md b/.changeset/proxy-error-visibility.md index b0f94cabee7..e054af5ed34 100644 --- a/.changeset/proxy-error-visibility.md +++ b/.changeset/proxy-error-visibility.md @@ -2,4 +2,4 @@ '@shopify/app': patch --- -Surface local proxy errors during `app dev` instead of silently 500ing on unmatched paths, destroying unmatched websocket upgrades, or crashing the process if the proxy fails to bind. +Surface local proxy errors during `app dev`: unmatched HTTP paths and websocket upgrades now log a warning instead of failing silently, the "Proxy server started" line only prints after the socket actually binds, and a fatal proxy error (failed bind, or a runtime error after bind) tears the dev session down instead of leaving the proxy dead with no further indication. diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index aa797ada007..43d8d004f5c 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -770,15 +770,22 @@ describe('startProxyServer', () => { } }) - test('resolves once the server is actually listening', async () => { + test('stays alive after bind and resolves cleanly when the abort signal fires', async () => { const abortController = new AbortController() - try { - await startProxyServer( - {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, - {port: 0, rules: {default: 'http://localhost:1'}}, - ) - } finally { - abortController.abort() - } + let resolved = false + const proxyPromise = startProxyServer( + {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, + {port: 0, rules: {default: 'http://localhost:1'}}, + ).then(() => { + resolved = true + }) + + // Give the proxy enough time to bind and enter its long-running wait. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(resolved).toBe(false) + + abortController.abort() + await expect(proxyPromise).resolves.toBeUndefined() + expect(resolved).toBe(true) }) }) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index cf9f5edc166..7eaa1277fc5 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -25,7 +25,8 @@ import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' import {isTruthy} from '@shopify/cli-kit/node/context/utilities' import {firstPartyDev} from '@shopify/cli-kit/node/context/local' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' -import {outputInfo} from '@shopify/cli-kit/node/output' +import {outputInfo, outputWarn} from '@shopify/cli-kit/node/output' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {adminFqdn} from '@shopify/cli-kit/node/context/fqdn' interface ProxyServerProcess extends BaseProcess<{ @@ -318,4 +319,28 @@ export const startProxyServer: DevProcessFunction<{ `Proxy server started on port ${port}${localhostCert ? ` with certificate ${localhostCert.certPath}` : ''}`, stdout, ) + + // Stay alive for the lifetime of the dev session. Resolve cleanly when the abort signal + // fires; reject if the server emits a post-bind 'error' so a dead proxy tears down the + // dev session instead of leaving the user with one warning line and silently broken + // request forwarding for the rest of the session. + await new Promise((resolve, reject) => { + const cleanup = () => { + abortSignal.removeEventListener('abort', onAbort) + server.off('error', onError) + } + const onAbort = () => { + cleanup() + resolve() + } + const onError = (err: Error) => { + cleanup() + useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { + outputWarn(`Proxy server error: ${err.message}`, stdout) + }) + reject(err) + } + server.on('error', onError) + abortSignal.addEventListener('abort', onAbort, {once: true}) + }) } diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index d5aee851c03..ba6848e10de 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -34,14 +34,6 @@ export async function getProxyingWebServer( // Capture websocket requests and forward them to the proxy server.on('upgrade', getProxyServerWebsocketUpgradeListener(rules, proxy, stdout)) - // Forward runtime errors from the underlying server (post-listen) to the proxy - // output instead of letting them bubble up as uncaught exceptions. - server.on('error', (err) => { - useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Proxy server error: ${err.message}`, stdout) - }) - }) - abortSignal.addEventListener('abort', () => { outputDebug('Closing reverse HTTP proxy') server.close() From 58764b4b5a05fffcf1507ee5154d2b38c6bbc876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 11:04:02 +0200 Subject: [PATCH 3/7] Rewrite proxy warnings in user-facing language Focus the messages on what happened and what to check, not on internal concepts like 'rules', 'forwarding', or 'targets'. Special-case EADDRINUSE and EACCES at bind time so the most common port-collision failure tells the user how to recover instead of dumping a Node errno. --- .../dev/processes/setup-dev-processes.test.ts | 4 +-- .../dev/processes/setup-dev-processes.ts | 25 +++++++++++++------ .../utilities/app/http-reverse-proxy.test.ts | 2 +- .../cli/utilities/app/http-reverse-proxy.ts | 21 ++++++++++------ 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index 43d8d004f5c..9e622cb3d37 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -750,7 +750,7 @@ describe('startProxyServer', () => { }, }) - test('rejects with the bind error when the port is already in use', async () => { + test('rejects with a user-friendly EADDRINUSE message when the port is already in use', async () => { // Hold a port so the proxy bind fails with EADDRINUSE. const blocker = http.createServer() await new Promise((resolve) => blocker.listen(0, 'localhost', resolve)) @@ -763,7 +763,7 @@ describe('startProxyServer', () => { {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, {port, rules: {default: 'http://localhost:1'}}, ), - ).rejects.toThrow(/EADDRINUSE/) + ).rejects.toThrow(`Port ${port} is already in use`) } finally { abortController.abort() await new Promise((resolve) => blocker.close(() => resolve())) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 7eaa1277fc5..131368b3a8a 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -302,9 +302,9 @@ export const startProxyServer: DevProcessFunction<{ // 'listening' and rejects on 'error' (e.g. EADDRINUSE) so a failed bind surfaces to the // dev runner instead of crashing Node via an uncaught 'error' event. await new Promise((resolve, reject) => { - const onError = (err: Error) => { + const onError = (err: NodeJS.ErrnoException) => { server.off('listening', onListening) - reject(err) + reject(translateBindError(err, port)) } const onListening = () => { server.off('error', onError) @@ -315,10 +315,7 @@ export const startProxyServer: DevProcessFunction<{ server.listen(port, 'localhost') }) - outputInfo( - `Proxy server started on port ${port}${localhostCert ? ` with certificate ${localhostCert.certPath}` : ''}`, - stdout, - ) + outputInfo(`Listening on port ${port}${localhostCert ? ` (HTTPS)` : ''}`, stdout) // Stay alive for the lifetime of the dev session. Resolve cleanly when the abort signal // fires; reject if the server emits a post-bind 'error' so a dead proxy tears down the @@ -336,7 +333,7 @@ export const startProxyServer: DevProcessFunction<{ const onError = (err: Error) => { cleanup() useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Proxy server error: ${err.message}`, stdout) + outputWarn(`Stopped unexpectedly: ${err.message}. Ending dev.`, stdout) }) reject(err) } @@ -344,3 +341,17 @@ export const startProxyServer: DevProcessFunction<{ abortSignal.addEventListener('abort', onAbort, {once: true}) }) } + +function translateBindError(err: NodeJS.ErrnoException, port: number): Error { + if (err.code === 'EADDRINUSE') { + return new Error( + `Port ${port} is already in use. Stop the other process listening on that port, or restart dev to pick a different one.`, + ) + } + if (err.code === 'EACCES') { + return new Error( + `Permission denied binding port ${port}. Use a port above 1024, or run with the required permissions.`, + ) + } + return new Error(`Couldn't start on port ${port}: ${err.message}`) +} diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts index 5b111fddd78..0821440dac6 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts @@ -88,7 +88,7 @@ describe.sequential('http-reverse-proxy without a default rule', () => { agent: new http.Agent({keepAlive: false}), }) expect(response.status).toBe(500) - await expect(response.text()).resolves.toContain('Invalid path') + await expect(response.text()).resolves.toContain('No process in your app is configured to serve') } finally { server.closeAllConnections() await new Promise((resolve) => server.close(() => resolve())) diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index ba6848e10de..c4902b2f0b1 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -53,13 +53,18 @@ function getProxyServerWebsocketUpgradeListener( useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err - outputWarn(`Error forwarding websocket request: ${error.message}`, stdout) - outputWarn(`└ Unreachable target "${target}" for path: "${req.url}"`, stdout) + outputWarn( + `Couldn't reach ${target} for websocket ${req.url ?? ''}: ${error.message}. Is the process running?`, + stdout, + ) }) }) } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`No matching websocket rule for "${req.url ?? ''}", closing connection`, stdout) + outputWarn( + `Got a websocket connection for ${req.url ?? ''} but nothing in your app handles that path. Closing.`, + stdout, + ) }) socket.destroy() } @@ -77,14 +82,16 @@ function getProxyServerRequestListener( useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err - outputWarn(`Error forwarding web request: ${error.message}`, stdout) - outputWarn(`└ Unreachable target "${target}" for path: "${req.url}"`, stdout) + outputWarn(`Couldn't reach ${target} for ${req.url ?? ''}: ${error.message}. Is the process running?`, stdout) }) }) } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`No matching rule for "${req.url ?? ''}", returning 500`, stdout) + outputWarn( + `Got a request for ${req.url ?? ''} but nothing in your app handles that path. Returning a 500.`, + stdout, + ) }) outputDebug(outputContent` Reverse HTTP proxy error - Invalid path: ${req.url ?? ''} @@ -93,7 +100,7 @@ ${outputToken.json(JSON.stringify(rules))} `) res.statusCode = 500 - res.end(`Invalid path ${req.url}`) + res.end(`No process in your app is configured to serve ${req.url}.`) } } From fee4c1ed849b4e1381dedcd9f4216e1eba483f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 11:19:22 +0200 Subject: [PATCH 4/7] Tighten proxy errors to one-line 'Error: local proxy ...' form Drop the prose advice and stick to the facts: every message names the component ('local proxy') and states what failed, on one line. The component name makes each line stand on its own even if the log prefix is missed; the format is consistent so the user can pattern-match across messages. --- .../dev/processes/setup-dev-processes.test.ts | 2 +- .../dev/processes/setup-dev-processes.ts | 16 ++++------------ .../cli/utilities/app/http-reverse-proxy.test.ts | 2 +- .../src/cli/utilities/app/http-reverse-proxy.ts | 16 +++++----------- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index 9e622cb3d37..017b7384d52 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -763,7 +763,7 @@ describe('startProxyServer', () => { {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, {port, rules: {default: 'http://localhost:1'}}, ), - ).rejects.toThrow(`Port ${port} is already in use`) + ).rejects.toThrow(`Error: local proxy port ${port} is already in use`) } finally { abortController.abort() await new Promise((resolve) => blocker.close(() => resolve())) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 131368b3a8a..3d68a82cf66 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -333,7 +333,7 @@ export const startProxyServer: DevProcessFunction<{ const onError = (err: Error) => { cleanup() useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Stopped unexpectedly: ${err.message}. Ending dev.`, stdout) + outputWarn(`Error: local proxy stopped (${err.message})`, stdout) }) reject(err) } @@ -343,15 +343,7 @@ export const startProxyServer: DevProcessFunction<{ } function translateBindError(err: NodeJS.ErrnoException, port: number): Error { - if (err.code === 'EADDRINUSE') { - return new Error( - `Port ${port} is already in use. Stop the other process listening on that port, or restart dev to pick a different one.`, - ) - } - if (err.code === 'EACCES') { - return new Error( - `Permission denied binding port ${port}. Use a port above 1024, or run with the required permissions.`, - ) - } - return new Error(`Couldn't start on port ${port}: ${err.message}`) + if (err.code === 'EADDRINUSE') return new Error(`Error: local proxy port ${port} is already in use`) + if (err.code === 'EACCES') return new Error(`Error: local proxy can't bind port ${port} (permission denied)`) + return new Error(`Error: local proxy couldn't start on port ${port} (${err.message})`) } diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts index 0821440dac6..077759d3e39 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts @@ -88,7 +88,7 @@ describe.sequential('http-reverse-proxy without a default rule', () => { agent: new http.Agent({keepAlive: false}), }) expect(response.status).toBe(500) - await expect(response.text()).resolves.toContain('No process in your app is configured to serve') + await expect(response.text()).resolves.toContain('No handler for') } finally { server.closeAllConnections() await new Promise((resolve) => server.close(() => resolve())) diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index c4902b2f0b1..6a954a85eed 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -54,17 +54,14 @@ function getProxyServerWebsocketUpgradeListener( const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err outputWarn( - `Couldn't reach ${target} for websocket ${req.url ?? ''}: ${error.message}. Is the process running?`, + `Error: local proxy couldn't reach websocket ${target} for ${req.url ?? ''} (${error.message})`, stdout, ) }) }) } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn( - `Got a websocket connection for ${req.url ?? ''} but nothing in your app handles that path. Closing.`, - stdout, - ) + outputWarn(`Error: local proxy has no handler for websocket ${req.url ?? ''}`, stdout) }) socket.destroy() } @@ -82,16 +79,13 @@ function getProxyServerRequestListener( useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err - outputWarn(`Couldn't reach ${target} for ${req.url ?? ''}: ${error.message}. Is the process running?`, stdout) + outputWarn(`Error: local proxy couldn't reach ${target} for ${req.url ?? ''} (${error.message})`, stdout) }) }) } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn( - `Got a request for ${req.url ?? ''} but nothing in your app handles that path. Returning a 500.`, - stdout, - ) + outputWarn(`Error: local proxy has no handler for ${req.url ?? ''}`, stdout) }) outputDebug(outputContent` Reverse HTTP proxy error - Invalid path: ${req.url ?? ''} @@ -100,7 +94,7 @@ ${outputToken.json(JSON.stringify(rules))} `) res.statusCode = 500 - res.end(`No process in your app is configured to serve ${req.url}.`) + res.end(`No handler for ${req.url}`) } } From e57c19ce6b2d4b2a07fbb3d7573c6e6060feeab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 12:13:14 +0200 Subject: [PATCH 5/7] Restore the original 'Proxy server started on port X' message --- .../src/cli/services/dev/processes/setup-dev-processes.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 3d68a82cf66..67613a89b6a 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -315,7 +315,10 @@ export const startProxyServer: DevProcessFunction<{ server.listen(port, 'localhost') }) - outputInfo(`Listening on port ${port}${localhostCert ? ` (HTTPS)` : ''}`, stdout) + outputInfo( + `Proxy server started on port ${port} ${localhostCert ? `with certificate ${localhostCert.certPath}` : ''}`, + stdout, + ) // Stay alive for the lifetime of the dev session. Resolve cleanly when the abort signal // fires; reject if the server emits a post-bind 'error' so a dead proxy tears down the From e4babfc8e88a464ae1f06ba4e84b6a4fcaeb1dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 12:56:07 +0200 Subject: [PATCH 6/7] Restore pre-existing proxy error messages The HTTP/WS forward error callbacks and the 500 response body were rewritten earlier in this PR; revert them to their original wording. The new messages I'm keeping are only the ones for paths that were previously silent: unmatched HTTP/WS routes, post-bind runtime errors, and bind-time error translation. --- .../src/cli/utilities/app/http-reverse-proxy.test.ts | 2 +- .../app/src/cli/utilities/app/http-reverse-proxy.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts index 077759d3e39..5b111fddd78 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts @@ -88,7 +88,7 @@ describe.sequential('http-reverse-proxy without a default rule', () => { agent: new http.Agent({keepAlive: false}), }) expect(response.status).toBe(500) - await expect(response.text()).resolves.toContain('No handler for') + await expect(response.text()).resolves.toContain('Invalid path') } finally { server.closeAllConnections() await new Promise((resolve) => server.close(() => resolve())) diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index 6a954a85eed..f26c71861bc 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -53,10 +53,8 @@ function getProxyServerWebsocketUpgradeListener( useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err - outputWarn( - `Error: local proxy couldn't reach websocket ${target} for ${req.url ?? ''} (${error.message})`, - stdout, - ) + outputWarn(`Error forwarding websocket request: ${error.message}`, stdout) + outputWarn(`└ Unreachable target "${target}" for path: "${req.url}"`, stdout) }) }) } @@ -79,7 +77,8 @@ function getProxyServerRequestListener( useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err - outputWarn(`Error: local proxy couldn't reach ${target} for ${req.url ?? ''} (${error.message})`, stdout) + outputWarn(`Error forwarding web request: ${error.message}`, stdout) + outputWarn(`└ Unreachable target "${target}" for path: "${req.url}"`, stdout) }) }) } @@ -94,7 +93,7 @@ ${outputToken.json(JSON.stringify(rules))} `) res.statusCode = 500 - res.end(`No handler for ${req.url}`) + res.end(`Invalid path ${req.url}`) } } From 85dccd4dccfab2e32bb651fef890cc7d147698c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 13:02:15 +0200 Subject: [PATCH 7/7] Match existing 'Reverse HTTP proxy error - ...' wording Align the new warnings and bind-error messages with the format already used in the outputDebug block for unmatched paths, so all proxy-related diagnostics share the same prefix and tone. --- .../services/dev/processes/setup-dev-processes.test.ts | 2 +- .../src/cli/services/dev/processes/setup-dev-processes.ts | 8 ++++---- packages/app/src/cli/utilities/app/http-reverse-proxy.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index 017b7384d52..c16adc26277 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -763,7 +763,7 @@ describe('startProxyServer', () => { {abortSignal: abortController.signal, stdout: sinkStream(), stderr: sinkStream()}, {port, rules: {default: 'http://localhost:1'}}, ), - ).rejects.toThrow(`Error: local proxy port ${port} is already in use`) + ).rejects.toThrow(`Reverse HTTP proxy error - Port ${port} is already in use`) } finally { abortController.abort() await new Promise((resolve) => blocker.close(() => resolve())) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 67613a89b6a..46bfa871d29 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -336,7 +336,7 @@ export const startProxyServer: DevProcessFunction<{ const onError = (err: Error) => { cleanup() useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Error: local proxy stopped (${err.message})`, stdout) + outputWarn(`Reverse HTTP proxy error - Server stopped: ${err.message}`, stdout) }) reject(err) } @@ -346,7 +346,7 @@ export const startProxyServer: DevProcessFunction<{ } function translateBindError(err: NodeJS.ErrnoException, port: number): Error { - if (err.code === 'EADDRINUSE') return new Error(`Error: local proxy port ${port} is already in use`) - if (err.code === 'EACCES') return new Error(`Error: local proxy can't bind port ${port} (permission denied)`) - return new Error(`Error: local proxy couldn't start on port ${port} (${err.message})`) + if (err.code === 'EADDRINUSE') return new Error(`Reverse HTTP proxy error - Port ${port} is already in use`) + if (err.code === 'EACCES') return new Error(`Reverse HTTP proxy error - Permission denied binding port ${port}`) + return new Error(`Reverse HTTP proxy error - Couldn't start on port ${port}: ${err.message}`) } diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index f26c71861bc..46d63c942b5 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -59,7 +59,7 @@ function getProxyServerWebsocketUpgradeListener( }) } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Error: local proxy has no handler for websocket ${req.url ?? ''}`, stdout) + outputWarn(`Reverse HTTP proxy error - Invalid websocket path: ${req.url ?? ''}`, stdout) }) socket.destroy() } @@ -84,7 +84,7 @@ function getProxyServerRequestListener( } useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { - outputWarn(`Error: local proxy has no handler for ${req.url ?? ''}`, stdout) + outputWarn(`Reverse HTTP proxy error - Invalid path: ${req.url ?? ''}`, stdout) }) outputDebug(outputContent` Reverse HTTP proxy error - Invalid path: ${req.url ?? ''}