From bfd15f07d1177a95ff3a050edf58cab2d5fd1b99 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 2 Jun 2026 15:40:45 -0700 Subject: [PATCH] Improve development environment loopback error message (#1526) --- .../development-environment/health/route.ts | 2 +- apps/dashboard/src/app/layout-client.tsx | 20 ++++- ...mote-development-environment-auth-gate.tsx | 74 +++++++++++++++---- .../src/app/wrong-address-screen.tsx | 28 +++++++ .../security.ts | 11 ++- 5 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 apps/dashboard/src/app/wrong-address-screen.tsx diff --git a/apps/dashboard/src/app/api/development-environment/health/route.ts b/apps/dashboard/src/app/api/development-environment/health/route.ts index e692a4576..d9d5ca024 100644 --- a/apps/dashboard/src/app/api/development-environment/health/route.ts +++ b/apps/dashboard/src/app/api/development-environment/health/route.ts @@ -81,7 +81,7 @@ async function localEmulatorIsHealthy(): Promise { export async function GET(req: NextRequest) { if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { - return NextResponse.json({ error: "Development environment health checks only accept loopback requests." }, { status: 403 }); + return NextResponse.json({ error: "You're accessing the development environment using an unsupported address (such as 'localhost'). Please go to http://127.0.0.1:26700 instead — copy and paste this address into your browser." }, { status: 403 }); } const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; diff --git a/apps/dashboard/src/app/layout-client.tsx b/apps/dashboard/src/app/layout-client.tsx index 25587338d..e2ad42aeb 100644 --- a/apps/dashboard/src/app/layout-client.tsx +++ b/apps/dashboard/src/app/layout-client.tsx @@ -16,12 +16,14 @@ import { DevelopmentPortDisplay } from "./development-port-display"; import Loading from "./loading"; import { UserIdentity } from "./providers"; import { RemoteDevelopmentEnvironmentAuthGate } from "./remote-development-environment-auth-gate"; +import { WrongAddressScreen } from "./wrong-address-screen"; const DEV_ENVIRONMENT_HEALTHCHECK_INTERVAL_MS = 2_000; type DevEnvironmentHealthSnapshot = | { status: "checking" | "healthy" } - | { status: "unhealthy", restartCommand: string }; + | { status: "unhealthy", restartCommand: string } + | { status: "wrong_address", suggestedUrl: string }; const CHECKING_DEV_ENVIRONMENT_HEALTH_SNAPSHOT: DevEnvironmentHealthSnapshot = { status: "checking" }; const HEALTHY_DEV_ENVIRONMENT_HEALTH_SNAPSHOT: DevEnvironmentHealthSnapshot = { status: "healthy" }; @@ -65,6 +67,18 @@ async function refreshDevEnvironmentHealth() { }, }); const body: unknown = await response.json(); + + // If the health endpoint returns a 403, the user is likely accessing via + // an unsupported address (e.g. localhost instead of 127.0.0.1). Extract + // the suggested URL from the error and show a dedicated screen. + if (response.status === 403 && body != null && typeof body === "object" && "error" in body && typeof body.error === "string") { + const match = body.error.match(/http:\/\/127\.0\.0\.1(?::\d+)?/); + if (match != null) { + setSnapshotIfCurrent({ status: "wrong_address", suggestedUrl: match[0] }); + return; + } + } + if (!isDevEnvironmentHealthResponse(body)) { throw new Error("Development environment health endpoint returned an invalid response."); } @@ -149,6 +163,10 @@ function DevEnvironmentHealthGate(props: { children: React.ReactNode }) { return props.children; } + if (health.status === "wrong_address") { + return ; + } + if (health.status === "unhealthy") { return ; } diff --git a/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx index 293b95a8e..335e41d7d 100644 --- a/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx +++ b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx @@ -6,6 +6,7 @@ import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { useStackApp } from "@hexclave/next"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { useEffect, useState } from "react"; +import { WrongAddressScreen } from "./wrong-address-screen"; const RDE_ACCESS_TOKEN_MIN_EXPIRATION_MS = 30_000; const RDE_ACCESS_TOKEN_MAX_AGE_MS = 60_000; @@ -87,6 +88,17 @@ function shouldRefreshAccessToken(token: RemoteDevelopmentEnvironmentAccessToken ); } +class LoopbackAddressError extends Error { + constructor(message: string, public readonly suggestedUrl: string) { + super(message); + } +} + +function extractLoopbackSuggestedUrl(errorMessage: string): string | null { + const match = errorMessage.match(/http:\/\/127\.0\.0\.1(?::\d+)?/); + return match?.[0] ?? null; +} + async function getRemoteDevelopmentEnvironmentAccessToken(): Promise { const response = await fetch("/api/remote-development-environment/auth", { headers: { @@ -94,7 +106,24 @@ async function getRemoteDevelopmentEnvironmentAccessToken(): Promise(null); useEffect(() => { let cancelled = false; @@ -120,21 +150,31 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac let currentToken: RemoteDevelopmentEnvironmentAccessTokenResponse | undefined; const refreshAccessToken = async (): Promise => { - const token = await installRemoteDevelopmentEnvironmentAccessToken(app); - const currentUser = await app.getUser({ - or: "anonymous-if-exists[deprecated]", - }); - if (currentUser?.id !== token.userId) { - throw new Error("Installed remote development environment token did not match the expected anonymous user."); - } - if (cancelled) return; - currentToken = token; - setAccessTokenInstalled(true); + try { + const token = await installRemoteDevelopmentEnvironmentAccessToken(app); + const currentUser = await app.getUser({ + or: "anonymous-if-exists[deprecated]", + }); + if (currentUser?.id !== token.userId) { + throw new Error("Installed remote development environment token did not match the expected anonymous user."); + } + if (cancelled) return; + currentToken = token; + setAccessTokenInstalled(true); - refreshTimeout = setTimeout(() => { - refreshPromise = undefined; - requestRefresh(); - }, getRefreshInMillis(token)); + refreshTimeout = setTimeout(() => { + refreshPromise = undefined; + requestRefresh(); + }, getRefreshInMillis(token)); + } catch (e) { + if (e instanceof LoopbackAddressError) { + if (!cancelled) { + setLoopbackError({ suggestedUrl: e.suggestedUrl }); + } + return; + } + throw e; + } }; const requestRefresh = (options?: { force?: boolean }) => { @@ -170,6 +210,10 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac }; }, [app]); + if (loopbackError != null) { + return ; + } + if (!accessTokenInstalled) { return ; } diff --git a/apps/dashboard/src/app/wrong-address-screen.tsx b/apps/dashboard/src/app/wrong-address-screen.tsx new file mode 100644 index 000000000..775e0e334 --- /dev/null +++ b/apps/dashboard/src/app/wrong-address-screen.tsx @@ -0,0 +1,28 @@ +"use client"; + +export function WrongAddressScreen(props: { suggestedUrl: string }) { + return ( +
+
+
+ Wrong address +
+

Use a different address to access this page

+

+ {"You're accessing the development environment using an address that isn't supported (such as "} + localhost + {")."} +

+

+ Please open this link instead: +

+ + {props.suggestedUrl} + +
+
+ ); +} diff --git a/apps/dashboard/src/lib/remote-development-environment/security.ts b/apps/dashboard/src/lib/remote-development-environment/security.ts index e30c4061b..aa60e799b 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.ts @@ -17,6 +17,13 @@ function requestHostIsLoopback(req: NextRequest): boolean { return isLocalhost(`http://${host}`); } +function loopbackRejectionMessage(req: NextRequest, state: RemoteDevelopmentEnvironmentState): string { + const dashboards = localDashboards(state); + const port = requestHostPort(req) ?? (dashboards.length > 0 ? dashboards[0].port : null); + const suggestedUrl = port != null ? `http://127.0.0.1:${port}` : "http://127.0.0.1:"; + return `You're accessing the development environment using an unsupported address (such as 'localhost'). Please go to ${suggestedUrl} instead — copy and paste this address into your browser.`; +} + function requestHostUrl(req: NextRequest): URL | null { const host = req.headers.get("host"); if (host == null) return null; @@ -74,7 +81,7 @@ export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): Nex const state = readRemoteDevelopmentEnvironmentState(); if (!requestHostIsLoopback(req)) { - return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); + return NextResponse.json({ error: loopbackRejectionMessage(req, state) }, { status: 403 }); } const expectedSecret = localDashboardSecretForRequest(req, state); @@ -101,7 +108,7 @@ export function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextReques } if (!requestHostIsLoopback(req) || !browserRequestOriginIsAllowed(req, state)) { - return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); + return NextResponse.json({ error: loopbackRejectionMessage(req, state) }, { status: 403 }); } const fetchSite = req.headers.get("sec-fetch-site");