mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Improve development environment loopback error message (#1526)
This commit is contained in:
parent
7d08af0db8
commit
bfd15f07d1
@ -81,7 +81,7 @@ async function localEmulatorIsHealthy(): Promise<boolean> {
|
||||
|
||||
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";
|
||||
|
||||
@ -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 <WrongAddressScreen suggestedUrl={health.suggestedUrl} />;
|
||||
}
|
||||
|
||||
if (health.status === "unhealthy") {
|
||||
return <DevEnvironmentStoppedScreen restartCommand={health.restartCommand} />;
|
||||
}
|
||||
|
||||
@ -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<RemoteDevelopmentEnvironmentAccessTokenResponse> {
|
||||
const response = await fetch("/api/remote-development-environment/auth", {
|
||||
headers: {
|
||||
@ -94,7 +106,24 @@ async function getRemoteDevelopmentEnvironmentAccessToken(): Promise<RemoteDevel
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to authenticate local remote development environment dashboard (${response.status}): ${await response.text()}`);
|
||||
const responseText = await response.text();
|
||||
// For 403 errors (e.g. accessing via 'localhost' instead of 127.0.0.1),
|
||||
// throw a LoopbackAddressError so the auth gate can show a dedicated screen
|
||||
if (response.status === 403) {
|
||||
try {
|
||||
const errorMessage = JSON.parse(responseText)?.error;
|
||||
if (typeof errorMessage === "string") {
|
||||
const suggestedUrl = extractLoopbackSuggestedUrl(errorMessage);
|
||||
if (suggestedUrl != null) {
|
||||
throw new LoopbackAddressError(errorMessage, suggestedUrl);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) throw e;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to authenticate local remote development environment dashboard (${response.status}): ${responseText}`);
|
||||
}
|
||||
|
||||
return parseRemoteDevelopmentEnvironmentAccessTokenResponse(await response.json());
|
||||
@ -112,6 +141,7 @@ async function installRemoteDevelopmentEnvironmentAccessToken(app: unknown): Pro
|
||||
function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.ReactNode }) {
|
||||
const app = useStackApp();
|
||||
const [accessTokenInstalled, setAccessTokenInstalled] = useState(false);
|
||||
const [loopbackError, setLoopbackError] = useState<{ suggestedUrl: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -120,21 +150,31 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac
|
||||
let currentToken: RemoteDevelopmentEnvironmentAccessTokenResponse | undefined;
|
||||
|
||||
const refreshAccessToken = async (): Promise<void> => {
|
||||
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 <WrongAddressScreen suggestedUrl={loopbackError.suggestedUrl} />;
|
||||
}
|
||||
|
||||
if (!accessTokenInstalled) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
28
apps/dashboard/src/app/wrong-address-screen.tsx
Normal file
28
apps/dashboard/src/app/wrong-address-screen.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
export function WrongAddressScreen(props: { suggestedUrl: string }) {
|
||||
return (
|
||||
<div className="relative z-10 min-h-screen bg-background text-foreground flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-lg rounded-2xl border border-black/[0.10] dark:border-white/[0.10] bg-white dark:bg-background p-6 shadow-sm">
|
||||
<div className="mb-3 inline-flex rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
Wrong address
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Use a different address to access this page</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{"You're accessing the development environment using an address that isn't supported (such as "}
|
||||
<code className="rounded bg-black/[0.04] dark:bg-white/[0.06] px-1 py-0.5 text-xs">localhost</code>
|
||||
{")."}
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-6 text-muted-foreground">
|
||||
Please open this link instead:
|
||||
</p>
|
||||
<a
|
||||
href={props.suggestedUrl}
|
||||
className="mt-3 block overflow-x-auto rounded-lg bg-black/[0.04] dark:bg-white/[0.06] px-3 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline"
|
||||
>
|
||||
{props.suggestedUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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:<port>";
|
||||
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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user