From 9c56c538c50d6c3af843e3b60069c4c8c3a8eecb Mon Sep 17 00:00:00 2001 From: "luis.silva" Date: Thu, 27 Aug 2026 17:23:34 +0100 Subject: [PATCH] fix(url-util): infer backendUrl from location when behind a path-prefixing proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backends that embed the prebuilt adk-web bundle (e.g. adk-java's AdkWebServer) serve runtime-config.json straight out of their own packaged artifact and have no build step to run set-backend.js against, so backendUrl is always "". URLUtil.getApiServerBaseUrl() then falls back to root-relative API calls (GET /list-apps), which breaks behind any reverse proxy that strips a path prefix before forwarding to the backend (e.g. WAP-style routing: /agents/my-agent/* -> container, prefix stripped) — the browser calls the unprefixed path and the proxy 404s. Derive a same-origin default from window.location instead of defaulting to root-relative, preserving whatever prefix this app was itself loaded under. An explicit runtimeConfig.backendUrl still takes precedence, so local dev via set-backend.js is unaffected. Also fixes getBaseUrlWithoutPath(), which hardcoded "/dev-ui/" off the origin and dropped any prefix the same way. --- src/utils/url-util.spec.ts | 83 ++++++++++++++++++++++++++++++++++++++ src/utils/url-util.ts | 50 ++++++++++++++++++----- 2 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 src/utils/url-util.spec.ts diff --git a/src/utils/url-util.spec.ts b/src/utils/url-util.spec.ts new file mode 100644 index 00000000..1c0f8f7b --- /dev/null +++ b/src/utils/url-util.spec.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// 1p-ONLY-IMPORTS: import {afterEach, beforeEach, describe, expect, it} + +import {URLUtil} from './url-util'; + +function fakeLocation(href: string) { + const url = new URL(href); + return { + href: url.href, + origin: url.origin, + pathname: url.pathname, + host: url.host, + }; +} + +describe('URLUtil', () => { + afterEach(() => { + delete (window as any)['runtimeConfig']; + }); + + describe('unprefixed deployment (plain `adk web`)', () => { + const location = fakeLocation('http://localhost:8080/dev-ui/session/abc'); + + it('getBaseUrlWithoutPath returns the server root + dev-ui', () => { + expect(URLUtil.getBaseUrlWithoutPath(location)) + .toBe('http://localhost:8080/dev-ui/'); + }); + + it('getApiServerBaseUrl falls back to same-origin (relative) when unset', + () => { + expect(URLUtil.getApiServerBaseUrl(location)).toBe(''); + }); + + it('getWSServerUrl falls back to the current host when unset', () => { + expect(URLUtil.getWSServerUrl(location)).toBe('localhost:8080'); + }); + }); + + describe('deployed behind a path-prefixing reverse proxy', () => { + const location = fakeLocation( + 'https://wap.example.com/agents/my-agent/dev-ui/session/abc'); + + it('getBaseUrlWithoutPath preserves the proxy prefix', () => { + expect(URLUtil.getBaseUrlWithoutPath(location)) + .toBe('https://wap.example.com/agents/my-agent/dev-ui/'); + }); + + it('getApiServerBaseUrl defaults to same-origin + the proxy prefix ' + + 'when runtime-config.json was not customized for this deployment', + () => { + expect(URLUtil.getApiServerBaseUrl(location)) + .toBe('https://wap.example.com/agents/my-agent'); + }); + + it('getWSServerUrl strips the scheme off the inferred prefix', () => { + expect(URLUtil.getWSServerUrl(location)) + .toBe('wap.example.com/agents/my-agent'); + }); + + it('an explicit runtimeConfig.backendUrl still takes precedence', () => { + (window as any)['runtimeConfig'] = { + backendUrl: 'https://api.example.com', + }; + expect(URLUtil.getApiServerBaseUrl(location)) + .toBe('https://api.example.com'); + }); + }); +}); diff --git a/src/utils/url-util.ts b/src/utils/url-util.ts index 96ebd6d8..cd71e944 100644 --- a/src/utils/url-util.ts +++ b/src/utils/url-util.ts @@ -17,34 +17,66 @@ import {env} from '../env/env'; +/** The subset of `Location` these helpers depend on, for testability. */ +type LocationLike = Pick; + export class URLUtil { + /** + * Get the path this app is mounted under, e.g. "/" for a plain `adk web` + * deployment or "/agents/my-agent/" when served behind a path-prefixing + * reverse proxy that forwards ".../agents/my-agent/dev-ui" through + * unmodified. Derived from the current location rather than assumed, + * since the app has no other way to learn a proxy prefix stripped + * upstream of it. + */ + private static getAppRootPath(location: LocationLike): string { + const path = location.pathname; + const devUiIndex = path.indexOf('/dev-ui'); + if (devUiIndex < 0) { + return '/'; + } + return path.slice(0, devUiIndex) + '/'; + } + /** * Get the base URL without any path * @returns {string} Base URL (protocol + hostname + port) */ - static getBaseUrlWithoutPath(): string { + static getBaseUrlWithoutPath(location: LocationLike = window.location): + string { // Use the URL constructor for robust URL parsing - const currentUrl = window.location.href; - const urlObject = new URL(currentUrl); + const urlObject = new URL(location.href); // Construct base URL using origin property // Origin includes protocol, hostname, and port - return urlObject.origin + '/dev-ui/'; + return urlObject.origin + URLUtil.getAppRootPath(location) + 'dev-ui/'; } /** * Get the base URL without any path * @returns {string} Base URL (protocol + hostname + port) */ - static getApiServerBaseUrl(): string { - return (window as any)['runtimeConfig']?.backendUrl || ''; + static getApiServerBaseUrl(location: LocationLike = window.location): + string { + const configured = (window as any)['runtimeConfig']?.backendUrl; + if (configured) { + return configured; + } + // No explicit backendUrl configured (runtime-config.json wasn't + // customized for this deployment). Fall back to same-origin, but + // preserve any prefix this app is itself mounted under so API calls + // still reach a reverse proxy that forwards that prefix through + // unmodified — rather than always assuming the API lives at the + // server root. + const root = URLUtil.getAppRootPath(location); + return root === '/' ? '' : location.origin + root.slice(0, -1); } - static getWSServerUrl(): string { - let url = URLUtil.getApiServerBaseUrl(); + static getWSServerUrl(location: LocationLike = window.location): string { + let url = URLUtil.getApiServerBaseUrl(location); // For adk web, when the api server is not set, use the current host if (!url || url == '') { - return window.location.host; + return location.host; } // For local development, api server address is passed in runtime_config