From 36605e0df8bf5163effccc4375864a93a841002e Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 18:30:53 +0530 Subject: [PATCH 1/3] feat: let the client be built for a sub-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It assumed it owned the origin root. Behind a shared domain at /experience, assets, fonts, config.json and the API were all absolute from /, so every one of them 404d — and the config failure was silent, because an unreachable config.json falls back to the bundled defaults. VITE_BASE_PATH is a Docker build arg, baked into the bundle. Default "/" is the origin root and nothing changes. An image built with a sub-path serves only from that path, so a deployment needing two paths builds two images; the path is known at build time, so that is an acceptable trade for the smaller mechanism. Vite rewrites what it can see — index.html, imported assets, CSS urls. Three things it cannot see needed the prefix by hand: - the API base and config.json URL - six icon paths in the header and recording controls, which are string literals pointing at public/ - the brand asset paths from config.json, which are data. A CDN URL is left alone; a path is resolved against the base The router needed it too. Without a basepath it matches against the origin root, so under a prefix the page loads, the config resolves and React mounts — and then every route 404s, which looks like a different bug entirely. Verified with a second nginx container as the edge proxy: built for /experience/ and served through it, and the default build served at the root, checking the page, the /chat route, config.json, the logo, the icons and the fonts in both. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 +++++ Dockerfile | 11 ++++++++++ src/bootstrap.tsx | 4 ++++ .../components/recording-controls.tsx | 8 ++++---- .../screens-component/chat-screen/config.ts | 19 +++++++++++++++--- .../screens-component/layouts/chat-header.tsx | 4 ++-- src/lib/api-service.ts | 20 ++++++++++++------- src/lib/config/branding.ts | 10 ++++++++-- src/lib/config/runtime-config.ts | 2 +- vite.config.ts | 4 ++++ 10 files changed, 68 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3aa1cb..ee18a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,9 @@ jobs: run: echo "TAG=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> "$GITHUB_ENV" # Loaded locally first so Trivy scans the exact image that gets pushed. + # VITE_BASE_PATH is baked into the bundle, so an image built for a + # sub-path serves only from there. The default "/" is the origin root; + # set the repository variable to publish for a sub-path instead. - name: Build (local, unpushed) uses: docker/build-push-action@v6 with: @@ -215,6 +218,8 @@ jobs: file: Dockerfile platforms: linux/amd64 load: true + build-args: | + VITE_BASE_PATH=${{ vars.VITE_BASE_PATH || '/' }} tags: experience-ui:${{ env.TAG }} # Report-only. It does not fail the build: a base-image CVE with no fix diff --git a/Dockerfile b/Dockerfile index 0b3c15f..db4972d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,17 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . + +# Where the app will be mounted, baked into the bundle. "/" is the origin root. +# An image built with a sub-path serves only from that path, so build one per +# path if a deployment needs more than one: +# +# docker build --build-arg VITE_BASE_PATH=/experience/ . +# +# Leading and trailing slashes both matter. +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=$VITE_BASE_PATH + RUN npm run build # ---- Serve ------------------------------------------------------------------ diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index b2ee834..e660fb9 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -23,6 +23,10 @@ const routeProgress = createRouteProgress(); const router = createRouter({ routeTree, + // Without this the router matches paths against the origin root, so every + // route 404s when the app is served from a sub-path — while the page itself + // loads fine, which makes it look like something else. + basepath: import.meta.env.BASE_URL, context: { queryClient }, defaultPendingComponent: () => (
diff --git a/src/components/screens-component/chat-screen/components/recording-controls.tsx b/src/components/screens-component/chat-screen/components/recording-controls.tsx index 11af668..2ac80f9 100644 --- a/src/components/screens-component/chat-screen/components/recording-controls.tsx +++ b/src/components/screens-component/chat-screen/components/recording-controls.tsx @@ -5,10 +5,10 @@ import { cn } from "@/lib/utils"; import { useLanguage } from "@/components/LanguageProvider"; import { CHAT_ASSISTANT } from "../config"; -const deleteIcon = "/assets/delete.svg"; -const micIcon = "/assets/micIcon.svg"; -const sendRecord = "/assets/sendRecord.svg"; -const pauseRecordIcon = "/assets/pauseRecordIcon.svg"; +const deleteIcon = `${import.meta.env.BASE_URL}assets/delete.svg`; +const micIcon = `${import.meta.env.BASE_URL}assets/micIcon.svg`; +const sendRecord = `${import.meta.env.BASE_URL}assets/sendRecord.svg`; +const pauseRecordIcon = `${import.meta.env.BASE_URL}assets/pauseRecordIcon.svg`; type RecordingState = "recording" | "paused"; diff --git a/src/components/screens-component/chat-screen/config.ts b/src/components/screens-component/chat-screen/config.ts index 9eeedce..4e756c7 100644 --- a/src/components/screens-component/chat-screen/config.ts +++ b/src/components/screens-component/chat-screen/config.ts @@ -26,18 +26,31 @@ export const DEFAULT_LANGUAGE: LanguageCode = rootConfig.defaultLanguage as Lang // CHAT CONFIGURATION // ============================================================================ +/** + * Resolves a configured asset URL against the path the app is served from. + * + * A deployment may point these at a CDN, in which case the URL is absolute and + * is left alone. A path is treated as relative to the app, so it keeps working + * when the app is mounted under a sub-path. + */ +const brandAsset = (value: string): string => { + if (!value) return value; + if (/^[a-z]+:\/\//i.test(value) || value.startsWith("data:")) return value; + return `${import.meta.env.BASE_URL}${value.replace(/^\//, "")}`; +}; + export const APP_NAME: string = rootConfig.brand.appName; -export const BRAND_LOGO: string = rootConfig.brand.logo; +export const BRAND_LOGO: string = brandAsset(rootConfig.brand.logo); export const CHAT_ASSISTANT = { name: rootConfig.brand.assistantName, - avatar: rootConfig.brand.assistantAvatar + avatar: brandAsset(rootConfig.brand.assistantAvatar) }; export const CHAT_USER = { name: "", - avatar: rootConfig.brand.userAvatar + avatar: brandAsset(rootConfig.brand.userAvatar) }; export const THEMES = { diff --git a/src/components/screens-component/layouts/chat-header.tsx b/src/components/screens-component/layouts/chat-header.tsx index 607a83e..73ef9b5 100644 --- a/src/components/screens-component/layouts/chat-header.tsx +++ b/src/components/screens-component/layouts/chat-header.tsx @@ -4,8 +4,8 @@ import { useLanguage } from "@/components/LanguageProvider"; import { BRAND_LOGO, LANGUAGES } from "@/components/screens-component/chat-screen/config"; import { FEATURES } from "@/lib/config/features"; import { LanguageSelectionDropdown } from "@/components/screens-component/chat-screen/components/language-selection-dialog"; -const settingsIcon = "/assets/settings.svg"; -const langIcon = "/assets/langIcon.svg"; +const settingsIcon = `${import.meta.env.BASE_URL}assets/settings.svg`; +const langIcon = `${import.meta.env.BASE_URL}assets/langIcon.svg`; export type ChatHeaderProps = { title: string; diff --git a/src/lib/api-service.ts b/src/lib/api-service.ts index b43ef3d..f48e98a 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -54,6 +54,12 @@ const stripSseKeepAliveMarkers = (chunk: string): string => .join('\n'); +/** + * Where the API lives. `environment.apiUrl` is empty, so it is same-origin; + * BASE_URL carries the sub-path the app was built for, if any. + */ +const apiBase = `${import.meta.env.BASE_URL}api/`; + class ApiService { private apiUrl: string = environment.apiUrl; private locationData: LocationData | null = null; @@ -62,7 +68,7 @@ class ApiService { constructor() { this.axiosInstance = axios.create({ - baseURL: this.apiUrl, + baseURL: this.apiUrl || apiBase, headers: { 'Content-Type': 'application/json' } @@ -99,7 +105,7 @@ class ApiService { if (onStreamData) { // Handle streaming response - const response = await fetch(`${this.apiUrl}/api/chat/?${new URLSearchParams(params)}`, { + const response = await fetch(`${this.apiUrl || apiBase}chat/?${new URLSearchParams(params)}`, { method: 'GET' }); @@ -150,7 +156,7 @@ class ApiService { const config = { params, }; - const response = await this.axiosInstance.get('/api/chat/', config); + const response = await this.axiosInstance.get('chat/', config); const responseQid = response.headers[CHAT_QID_HEADER.toLowerCase()] as string | undefined; onResponseStarted?.(); return { @@ -193,7 +199,7 @@ class ApiService { const formData = new FormData(); formData.append('image', imageFile); - const response = await fetch(`${this.apiUrl}/api/image/upload`, { + const response = await fetch(`${this.apiUrl || apiBase}image/upload`, { method: 'POST', body: formData }); @@ -245,7 +251,7 @@ class ApiService { params, }; - const response = await this.axiosInstance.get('/api/suggest/', config); + const response = await this.axiosInstance.get('suggest/', config); return response.data.map((item: string) => ({ question: item })); @@ -277,7 +283,7 @@ class ApiService { const config = { }; - const response = await this.axiosInstance.post('/api/transcribe/', payload, config); + const response = await this.axiosInstance.post('transcribe/', payload, config); return response.data; } catch (error) { console.error('Error transcribing audio:', error); @@ -295,7 +301,7 @@ class ApiService { timeout: 120000, // 120s timeout for TTS (can be slow on cold start) }; - return this.axiosInstance.post(`/api/tts/`, { + return this.axiosInstance.post(`tts/`, { session_id: sessionId, text: text, target_lang: targetLang diff --git a/src/lib/config/branding.ts b/src/lib/config/branding.ts index 1306b19..aded830 100644 --- a/src/lib/config/branding.ts +++ b/src/lib/config/branding.ts @@ -20,11 +20,17 @@ export const applyBranding = (): void => { document.title = brand.documentTitle; } - if (brand.favicon) { + // Same treatment as the other brand assets: a CDN URL is absolute and left + // alone, a path is relative to wherever the app is served from. + const favicon = brand.favicon && !/^[a-z]+:\/\//i.test(brand.favicon) + ? `${import.meta.env.BASE_URL}${brand.favicon.replace(/^\//, "")}` + : brand.favicon; + + if (favicon) { const existing = document.querySelector('link[rel="icon"]'); const link = existing ?? document.createElement("link"); link.rel = "icon"; - link.href = brand.favicon; + link.href = favicon; // The markup declares a type for its own SVG fallback. A configured // icon may be any format, so let the browser sniff it instead. link.removeAttribute("type"); diff --git a/src/lib/config/runtime-config.ts b/src/lib/config/runtime-config.ts index 7f2a465..6a2885c 100644 --- a/src/lib/config/runtime-config.ts +++ b/src/lib/config/runtime-config.ts @@ -18,7 +18,7 @@ import bundledConfig from "@/config/app-config.json"; export type AppConfig = typeof bundledConfig; -const CONFIG_URL = "/config.json"; +const CONFIG_URL = `${import.meta.env.BASE_URL}config.json`; let active: AppConfig = bundledConfig; let loaded = false; diff --git a/vite.config.ts b/vite.config.ts index c9c614b..258a6d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,10 @@ const appConfigPlugin: PluginOption = { // https://vite.dev/config/ export default defineConfig({ + // Where the app will be mounted. Baked in at build time, so an image built + // with a sub-path only serves from that path. Supplied by the Docker build + // arg VITE_BASE_PATH; "/" is the default and means the origin root. + base: process.env.VITE_BASE_PATH || "/", resolve: { alias: { "@": path.resolve(__dirname, "src"), From c4a2da48895bcb4305121d0ff4cc118dd51f3bf5 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 18:31:28 +0530 Subject: [PATCH 2/3] docs: record how to serve the client from a sub-path Covers the build arg, the two nginx blocks it needs, and the constraint that an image built for a sub-path serves only from that path. Also notes the trap that cost time while testing: nginx resolves a proxy_pass hostname once at config load, so restarting the app container gives a 502 that looks like the app being down until the proxy is reloaded. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index d6ba33e..b2baf80 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,33 @@ docker run -p 8080:8080 experience-ui `GET /healthz` returns `ok` for whatever is in front. +### Serving from a sub-path + +The client is built for the origin root by default. To mount it under a path on +a shared domain, build with that path and have the proxy strip the prefix: + +```bash +docker build --build-arg VITE_BASE_PATH=/experience/ -t experience-ui . +``` + +```nginx +location /experience/ { + proxy_pass http://experience-ui:8080/; # the trailing slash strips the prefix +} + +location = /experience { + return 301 /experience/; # otherwise a bare /experience 404s +} +``` + +Leading and trailing slashes on `VITE_BASE_PATH` both matter. The path is baked +into the bundle, so **an image built for a sub-path serves only from that +path** — a deployment that needs two paths builds two images. In CI it comes +from the `VITE_BASE_PATH` repository variable, defaulting to `/`. + +Note that nginx resolves a `proxy_pass` hostname once at config load: restart +the app container and the proxy keeps the old address until it is reloaded too. + ### Configuring a deployment Everything a deployment changes — name, logo, favicon, colours, which features From abef2e3182bfbe80fa697a91974bebfead4b6939 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 18:35:17 +0530 Subject: [PATCH 3/3] refactor: use configured asset paths as written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit resolved brand asset paths through a helper that stripped leading slashes and skipped anything that looked like a URL. The browser already does this: a relative URL resolves against the page, an absolute one is left alone. The helper was guarding against nothing, and I had it duplicated in two files. Config values are now read straight through. The contract is stated in the README instead: a file under public/ is referenced without a leading slash, and anything else is a full URL. import.meta.env.BASE_URL stays where the path is code rather than config — the API base, the config.json URL, the router basepath and the six public/ icon paths. That is Vite's own idiom for exactly this. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 14 ++++++++++++++ .../screens-component/chat-screen/config.ts | 19 +++---------------- src/config/app-config.json | 6 +++--- src/lib/config/branding.ts | 10 ++-------- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b2baf80..c3791b2 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,20 @@ from the `VITE_BASE_PATH` repository variable, defaulting to `/`. Note that nginx resolves a `proxy_pass` hostname once at config load: restart the app container and the proxy keeps the old address until it is reloaded too. +### Brand assets in `config.json` + +`logo`, `favicon` and `assistantAvatar` are used exactly as written — nothing +rewrites them. Two forms work: + +```json +"logo": "brand/logo.png", // a file served by this app +"logo": "https://cdn.example.org/logo.svg" // anywhere else +``` + +For a file, put it under `public/` and reference it **without a leading +slash**, so it resolves relative to wherever the app is served from. A leading +slash pins it to the domain root and breaks a sub-path deployment. + ### Configuring a deployment Everything a deployment changes — name, logo, favicon, colours, which features diff --git a/src/components/screens-component/chat-screen/config.ts b/src/components/screens-component/chat-screen/config.ts index 4e756c7..9eeedce 100644 --- a/src/components/screens-component/chat-screen/config.ts +++ b/src/components/screens-component/chat-screen/config.ts @@ -26,31 +26,18 @@ export const DEFAULT_LANGUAGE: LanguageCode = rootConfig.defaultLanguage as Lang // CHAT CONFIGURATION // ============================================================================ -/** - * Resolves a configured asset URL against the path the app is served from. - * - * A deployment may point these at a CDN, in which case the URL is absolute and - * is left alone. A path is treated as relative to the app, so it keeps working - * when the app is mounted under a sub-path. - */ -const brandAsset = (value: string): string => { - if (!value) return value; - if (/^[a-z]+:\/\//i.test(value) || value.startsWith("data:")) return value; - return `${import.meta.env.BASE_URL}${value.replace(/^\//, "")}`; -}; - export const APP_NAME: string = rootConfig.brand.appName; -export const BRAND_LOGO: string = brandAsset(rootConfig.brand.logo); +export const BRAND_LOGO: string = rootConfig.brand.logo; export const CHAT_ASSISTANT = { name: rootConfig.brand.assistantName, - avatar: brandAsset(rootConfig.brand.assistantAvatar) + avatar: rootConfig.brand.assistantAvatar }; export const CHAT_USER = { name: "", - avatar: brandAsset(rootConfig.brand.userAvatar) + avatar: rootConfig.brand.userAvatar }; export const THEMES = { diff --git a/src/config/app-config.json b/src/config/app-config.json index 5564d58..2866f49 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -52,9 +52,9 @@ "appName": "Agri Assistant", "assistantName": "Assistant", "documentTitle": "Agri Assistant", - "logo": "/brand/logo.png", - "favicon": "/brand/favicon.svg", - "assistantAvatar": "/brand/assistant.svg", + "logo": "brand/logo.png", + "favicon": "brand/favicon.svg", + "assistantAvatar": "brand/assistant.svg", "userAvatar": "" }, "languages": [ diff --git a/src/lib/config/branding.ts b/src/lib/config/branding.ts index aded830..1306b19 100644 --- a/src/lib/config/branding.ts +++ b/src/lib/config/branding.ts @@ -20,17 +20,11 @@ export const applyBranding = (): void => { document.title = brand.documentTitle; } - // Same treatment as the other brand assets: a CDN URL is absolute and left - // alone, a path is relative to wherever the app is served from. - const favicon = brand.favicon && !/^[a-z]+:\/\//i.test(brand.favicon) - ? `${import.meta.env.BASE_URL}${brand.favicon.replace(/^\//, "")}` - : brand.favicon; - - if (favicon) { + if (brand.favicon) { const existing = document.querySelector('link[rel="icon"]'); const link = existing ?? document.createElement("link"); link.rel = "icon"; - link.href = favicon; + link.href = brand.favicon; // The markup declares a type for its own SVG fallback. A configured // icon may be any format, so let the browser sniff it instead. link.removeAttribute("type");