mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat: centralise and simplify the url configuration
This commit is contained in:
parent
225f369cba
commit
23382b8afb
5 changed files with 135 additions and 92 deletions
|
|
@ -36,6 +36,7 @@ export async function GET() {
|
|||
let turnEnabled = false;
|
||||
let forceTurnRelay = false;
|
||||
let tunnelUrl: string | null = null;
|
||||
let backendApiEndpoint: string | null = null;
|
||||
let backendStatus: "reachable" | "unreachable" = "unreachable";
|
||||
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
|
||||
|
||||
|
|
@ -55,6 +56,11 @@ export async function GET() {
|
|||
turnEnabled = Boolean(data.turn_enabled);
|
||||
forceTurnRelay = Boolean(data.force_turn_relay);
|
||||
tunnelUrl = data.tunnel_url ?? null;
|
||||
backendApiEndpoint =
|
||||
typeof data.backend_api_endpoint === "string" &&
|
||||
data.backend_api_endpoint.length > 0
|
||||
? trimTrailingSlash(data.backend_api_endpoint)
|
||||
: null;
|
||||
backendStatus = "reachable";
|
||||
backendMessage = null;
|
||||
}
|
||||
|
|
@ -71,6 +77,7 @@ export async function GET() {
|
|||
turnEnabled,
|
||||
forceTurnRelay,
|
||||
tunnelUrl,
|
||||
backendApiEndpoint,
|
||||
backend: {
|
||||
status: backendStatus,
|
||||
url: backendUrl,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { TurnCredentialsResponse } from "@/client/types.gen";
|
|||
import { WorkflowValidationError } from "@/components/flow/types";
|
||||
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import { sdpFilterCodec } from "../utils";
|
||||
|
|
@ -37,34 +38,6 @@ const HANDLED_SERVICE_ERROR_TYPES = new Set([
|
|||
'quota_check_failed',
|
||||
]);
|
||||
|
||||
const LOCALHOST_API_BASE_URL = 'http://localhost:8000';
|
||||
const LOCALHOST_API_HEALTH_URL = `${LOCALHOST_API_BASE_URL}/api/v1/health`;
|
||||
const LOCALHOST_API_PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
function isLocalhostUi() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
|
||||
}
|
||||
|
||||
async function probeLocalhostApi() {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), LOCALHOST_API_PROBE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(LOCALHOST_API_HEALTH_URL, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
|
||||
const [connectionActive, setConnectionActive] = useState(false);
|
||||
|
|
@ -137,41 +110,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
const currentAllowInterruptRef = useRef<boolean | undefined>(undefined);
|
||||
const interruptWarningShownRef = useRef(false);
|
||||
|
||||
const getWebSocketUrl = useCallback(async () => {
|
||||
// An explicitly configured backend URL always wins. When set, honor it
|
||||
// verbatim and skip the localhost autodetect below — the operator has
|
||||
// told us exactly where the API lives. Read the env var directly (not
|
||||
// client.getConfig().baseUrl) so we can distinguish "explicitly set"
|
||||
// from the client's window.location.origin fallback.
|
||||
const configuredBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
|
||||
let baseUrl: string;
|
||||
|
||||
if (configuredBackendUrl) {
|
||||
baseUrl = configuredBackendUrl;
|
||||
} else if (isLocalhostUi()) {
|
||||
// No backend URL configured and the UI is on localhost: the client
|
||||
// would otherwise fall back to window.location.origin (the UI port,
|
||||
// e.g. 3010), which is wrong for the API. Local Docker exposes the
|
||||
// API on localhost:8000. WebSocket upgrades cannot pass through the
|
||||
// Next.js route-handler HTTP proxy, so connect to the API directly
|
||||
// when that port is reachable. A Next.js rewrite/proxy for the
|
||||
// upgrade was considered, but we keep the WebRTC signaling path
|
||||
// direct so signaling and the API's ICE/WebRTC handling terminate
|
||||
// at the same local endpoint.
|
||||
const localhostApiReachable = await probeLocalhostApi();
|
||||
|
||||
if (!localhostApiReachable) {
|
||||
throw new Error('Dograh API is not reachable at http://localhost:8000. Ensure the api container is running and port 8000 is published.');
|
||||
}
|
||||
|
||||
baseUrl = LOCALHOST_API_BASE_URL;
|
||||
} else {
|
||||
// Same-origin deployment: UI and API share an origin.
|
||||
baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
|
||||
}
|
||||
|
||||
// Convert HTTP to WS protocol
|
||||
const getWebSocketUrl = useCallback(() => {
|
||||
// Single source of truth for the browser→API base URL: the centrally
|
||||
// resolved API client config (NEXT_PUBLIC_BACKEND_URL → the backend
|
||||
// endpoint reported by /health → window.location.origin), seeded by
|
||||
// createClientConfig and upgraded by AppConfigProvider. The backend now
|
||||
// reports the endpoint it runs on, so the old localhost autodetect that
|
||||
// forced :8000 (back when an unset endpoint fell through to the UI origin)
|
||||
// is no longer needed.
|
||||
const baseUrl = client.getConfig().baseUrl || resolveBrowserBackendUrl();
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws');
|
||||
return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`;
|
||||
}, [workflowId, workflowRunId, accessToken]);
|
||||
|
|
@ -352,7 +299,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
};
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
const wsUrl = await getWebSocketUrl();
|
||||
const wsUrl = getWebSocketUrl();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
logger.info(`Connecting to WebSocket: ${wsUrl}`);
|
||||
|
|
|
|||
|
|
@ -6,22 +6,41 @@ import { useState } from "react";
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { resolveBrowserBackendUrl } from "@/lib/apiClient";
|
||||
|
||||
const MCP_PATH = "/api/v1/mcp/";
|
||||
|
||||
export function MCPSection() {
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
const endpoint = `${backendUrl}/api/v1/mcp/`;
|
||||
const { config } = useAppConfig();
|
||||
// Backend URL: the address the deployment runs on (a private IP when the backend
|
||||
// sits on one). Tunnel URL, when present: the publicly reachable Cloudflare tunnel
|
||||
// URL externally-hosted assistants should use to reach an otherwise-private host.
|
||||
const backendUrl = resolveBrowserBackendUrl(config?.backendApiEndpoint);
|
||||
const tunnelUrl = config?.tunnelUrl ?? null;
|
||||
|
||||
const [endpointCopied, setEndpointCopied] = useState(false);
|
||||
const endpoints = [
|
||||
...(tunnelUrl
|
||||
? [
|
||||
{
|
||||
key: "tunnel",
|
||||
label: "Public URL (Cloudflare tunnel)",
|
||||
url: `${tunnelUrl}${MCP_PATH}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: "backend", label: "Backend URL", url: `${backendUrl}${MCP_PATH}` },
|
||||
];
|
||||
|
||||
const handleCopy = async (
|
||||
value: string,
|
||||
setter: (v: boolean) => void,
|
||||
) => {
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (value: string, key: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
setCopiedKey(key);
|
||||
setTimeout(
|
||||
() => setCopiedKey((current) => (current === key ? null : current)),
|
||||
2000,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -39,23 +58,40 @@ export function MCPSection() {
|
|||
Get your API key
|
||||
</Link>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
|
||||
{endpoint}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => handleCopy(endpoint, setEndpointCopied)}
|
||||
>
|
||||
{endpointCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="grid gap-3">
|
||||
{endpoints.map(({ key, label, url }) => (
|
||||
<div key={key} className="grid gap-1">
|
||||
{endpoints.length > 1 && (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
|
||||
{url}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => handleCopy(url, key)}
|
||||
>
|
||||
{copiedKey === key ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tunnelUrl && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use the public URL from externally-hosted assistants; the backend URL
|
||||
works from the deployment's own network.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
|
||||
|
||||
type BackendStatus = 'reachable' | 'unreachable';
|
||||
|
||||
interface AppConfig {
|
||||
|
|
@ -14,6 +17,11 @@ interface AppConfig {
|
|||
// Public URL when the deployment is reached through a Cloudflare tunnel
|
||||
// (host has no public IP); null for a directly-reachable deployment.
|
||||
tunnelUrl: string | null;
|
||||
// The URL the backend reports it is running on (via /health). This is the
|
||||
// address the browser reaches the backend at — a private IP when the backend
|
||||
// runs on one. null until /health is reached. Used to resolve the API client
|
||||
// base URL; distinct from tunnelUrl, which is only for external consumers.
|
||||
backendApiEndpoint: string | null;
|
||||
backendStatus: BackendStatus;
|
||||
backendUrl: string;
|
||||
backendMessage: string | null;
|
||||
|
|
@ -33,6 +41,7 @@ const defaultConfig: AppConfig = {
|
|||
turnEnabled: false,
|
||||
forceTurnRelay: false,
|
||||
tunnelUrl: null,
|
||||
backendApiEndpoint: null,
|
||||
backendStatus: 'unreachable',
|
||||
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
|
||||
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
|
||||
|
|
@ -60,6 +69,21 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
|||
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
|
||||
? backend.url
|
||||
: defaultConfig.backendUrl;
|
||||
const backendApiEndpoint = typeof data.backendApiEndpoint === 'string' && data.backendApiEndpoint.length > 0
|
||||
? data.backendApiEndpoint
|
||||
: null;
|
||||
|
||||
// createClientConfig seeds the API client base URL before /health is
|
||||
// known. Now that the backend has reported the endpoint it runs on,
|
||||
// re-apply the single browser→API preference order so all SDK calls
|
||||
// (and anything reading client.getConfig().baseUrl) hit it directly —
|
||||
// window.location.origin would be wrong when the API is served from a
|
||||
// different host/port. resolveBrowserBackendUrl keeps NEXT_PUBLIC_BACKEND_URL
|
||||
// ahead of the reported endpoint. Guard on a present endpoint so a
|
||||
// transient /health failure never downgrades a good base URL to origin.
|
||||
if (backendApiEndpoint) {
|
||||
client.setConfig({ baseUrl: resolveBrowserBackendUrl(backendApiEndpoint) });
|
||||
}
|
||||
|
||||
setConfig({
|
||||
uiVersion: data.ui || 'dev',
|
||||
|
|
@ -69,6 +93,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
|||
turnEnabled: Boolean(data.turnEnabled),
|
||||
forceTurnRelay: Boolean(data.forceTurnRelay),
|
||||
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
|
||||
backendApiEndpoint,
|
||||
backendStatus,
|
||||
backendUrl,
|
||||
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
|
||||
|
|
|
|||
|
|
@ -5,6 +5,31 @@ export function getServerBackendUrl() {
|
|||
return process.env.BACKEND_URL || 'http://api:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base URL the browser should use to reach the backend API.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. NEXT_PUBLIC_BACKEND_URL — explicit build-time operator config, always wins.
|
||||
* 2. backendApiEndpoint — the URL the backend reports it is running on via /health
|
||||
* (surfaced through AppConfigContext). This is the address the browser actually
|
||||
* reaches the backend at; for a backend on a private IP it is that private IP.
|
||||
* Unknown at module init, so createClientConfig seeds without it and
|
||||
* AppConfigProvider upgrades the client once /health resolves.
|
||||
* 3. window.location.origin — same-origin public deployment.
|
||||
*
|
||||
* This is the browser→API order. It is intentionally NOT tunnel-aware: the
|
||||
* Cloudflare tunnel URL is only for externally-hosted consumers (telephony
|
||||
* webhooks, MCP, external API triggers) that cannot reach a private IP — see
|
||||
* resolveWebhookBaseUrl.
|
||||
*/
|
||||
export function resolveBrowserBackendUrl(backendApiEndpoint?: string | null): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
backendApiEndpoint ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
);
|
||||
}
|
||||
|
||||
export const createClientConfig: CreateClientConfig = (config) => {
|
||||
// Use different URLs for server-side vs client-side
|
||||
const isServer = typeof window === 'undefined';
|
||||
|
|
@ -13,7 +38,10 @@ export const createClientConfig: CreateClientConfig = (config) => {
|
|||
if (isServer) {
|
||||
baseUrl = getServerBackendUrl();
|
||||
} else {
|
||||
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
||||
// The backend-reported endpoint is not known yet at module init;
|
||||
// AppConfigProvider upgrades the client base URL once /health reports it
|
||||
// (when no explicit NEXT_PUBLIC_BACKEND_URL is configured).
|
||||
baseUrl = resolveBrowserBackendUrl();
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue