mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
feat(scripts): free trusted HTTPS via sslip.io for public-IP remote i… (#460)
* feat(scripts): free trusted HTTPS via sslip.io for public-IP remote installs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: refactor setup scripts * chore: generate sdk * chore: fix messaging for setup_remote script * fix: fix ffmpeg download url * feat: centralise and simplify the url configuration * fix: force script run as sudo * fix: fix documentation --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3309face2c
commit
78427817a6
30 changed files with 838 additions and 392 deletions
|
|
@ -35,6 +35,8 @@ export async function GET() {
|
|||
let authProvider = "local";
|
||||
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}.`;
|
||||
|
||||
|
|
@ -53,6 +55,12 @@ export async function GET() {
|
|||
authProvider = data.auth_provider;
|
||||
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;
|
||||
}
|
||||
|
|
@ -68,6 +76,8 @@ export async function GET() {
|
|||
authProvider,
|
||||
turnEnabled,
|
||||
forceTurnRelay,
|
||||
tunnelUrl,
|
||||
backendApiEndpoint,
|
||||
backend: {
|
||||
status: backendStatus,
|
||||
url: backendUrl,
|
||||
|
|
|
|||
|
|
@ -55,24 +55,21 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
|
||||
|
||||
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
|
||||
|
||||
function getInboundWebhookUrl(): string {
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
return `${backendUrl}${INBOUND_WEBHOOK_PATH}`;
|
||||
}
|
||||
|
||||
export default function TelephonyConfigurationDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ configId: string }>();
|
||||
const configId = Number(params.configId);
|
||||
|
||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const inboundWebhookUrl = `${resolveWebhookBaseUrl(appConfig?.tunnelUrl)}${INBOUND_WEBHOOK_PATH}`;
|
||||
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = getInboundWebhookUrl();
|
||||
const url = inboundWebhookUrl;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => toast.success("Inbound webhook URL copied"))
|
||||
|
|
@ -275,7 +272,7 @@ export default function TelephonyConfigurationDetailPage() {
|
|||
aria-label="Copy inbound webhook URL"
|
||||
className="inline-flex items-center gap-1 self-start rounded font-mono text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span className="truncate">{getInboundWebhookUrl()}</span>
|
||||
<span className="truncate">{inboundWebhookUrl}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -2830,6 +2830,10 @@ export type HealthResponse = {
|
|||
* Backend Api Endpoint
|
||||
*/
|
||||
backend_api_endpoint: string;
|
||||
/**
|
||||
* Tunnel Url
|
||||
*/
|
||||
tunnel_url?: string | null;
|
||||
/**
|
||||
* Deployment Mode
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import { FlowNodeData } from "@/components/flow/types";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
|
||||
|
||||
import { NodeContent } from "./common/NodeContent";
|
||||
import { NodeEditDialog } from "./common/NodeEditDialog";
|
||||
|
|
@ -90,14 +92,12 @@ interface TriggerEndpoints {
|
|||
|
||||
function buildTriggerEndpoints(
|
||||
triggerPath: string | undefined,
|
||||
baseUrl: string,
|
||||
): TriggerEndpoints {
|
||||
if (!triggerPath) return { production: "", test: "" };
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
return {
|
||||
production: `${backendUrl}/api/v1/public/agent/${triggerPath}`,
|
||||
test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`,
|
||||
production: `${baseUrl}/api/v1/public/agent/${triggerPath}`,
|
||||
test: `${baseUrl}/api/v1/public/agent/test/${triggerPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -182,8 +182,12 @@ function CanvasPreview({
|
|||
onStaleTools: (uuids: string[]) => void;
|
||||
onStaleDocuments: (uuids: string[]) => void;
|
||||
}) {
|
||||
const { config: appConfig } = useAppConfig();
|
||||
if (spec.name === "trigger") {
|
||||
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
|
||||
const endpoint = buildTriggerEndpoints(
|
||||
data.trigger_path,
|
||||
resolveWebhookBaseUrl(appConfig?.tunnelUrl),
|
||||
).production;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">API Endpoint:</p>
|
||||
|
|
@ -474,7 +478,9 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
});
|
||||
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
|
||||
const { bySpecName } = useNodeSpecs();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const spec = bySpecName.get(type);
|
||||
const webhookBaseUrl = resolveWebhookBaseUrl(appConfig?.tunnelUrl);
|
||||
|
||||
// ── Form state ─────────────────────────────────────────────────────
|
||||
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
|
||||
|
|
@ -500,12 +506,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
// ── Trigger auto-UUID + canvas copy state ──────────────────────────
|
||||
const [triggerCopied, setTriggerCopied] = useState(false);
|
||||
const handleCopyTrigger = useCallback(async () => {
|
||||
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
|
||||
const endpoint = buildTriggerEndpoints(data.trigger_path, webhookBaseUrl).production;
|
||||
if (!endpoint) return;
|
||||
await navigator.clipboard.writeText(endpoint);
|
||||
setTriggerCopied(true);
|
||||
setTimeout(() => setTriggerCopied(false), 2000);
|
||||
}, [data.trigger_path]);
|
||||
}, [data.trigger_path, webhookBaseUrl]);
|
||||
|
||||
// For trigger nodes without a path yet, generate one and persist.
|
||||
useEffect(() => {
|
||||
|
|
@ -684,7 +690,7 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
/>
|
||||
{type === "trigger" && (
|
||||
<TriggerWebhookUrls
|
||||
endpoints={buildTriggerEndpoints(data.trigger_path)}
|
||||
endpoints={buildTriggerEndpoints(data.trigger_path, webhookBaseUrl)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -11,6 +14,14 @@ interface AppConfig {
|
|||
authProvider: string;
|
||||
turnEnabled: boolean;
|
||||
forceTurnRelay: boolean;
|
||||
// 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;
|
||||
|
|
@ -29,6 +40,8 @@ const defaultConfig: AppConfig = {
|
|||
authProvider: 'local',
|
||||
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
|
||||
|
|
@ -56,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',
|
||||
|
|
@ -64,6 +92,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
|||
authProvider: data.authProvider || 'local',
|
||||
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 {
|
||||
|
|
|
|||
16
ui/src/lib/webhookUrl.ts
Normal file
16
ui/src/lib/webhookUrl.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Public base URL that external callers (telephony providers, webhook senders)
|
||||
* should use to reach this deployment's API.
|
||||
*
|
||||
* Prefers the Cloudflare tunnel URL the backend reports via /health (set when the
|
||||
* host has no public IP, so `window.location.origin` would be a private/LAN
|
||||
* address an external caller can't reach), then a build-time configured backend
|
||||
* URL, then the current origin (correct for a same-origin public deployment).
|
||||
*/
|
||||
export function resolveWebhookBaseUrl(tunnelUrl?: string | null): string {
|
||||
return (
|
||||
tunnelUrl ||
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "")
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue