From 3f49a0c6efef3bd597a344df861b6084aed53f63 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 18:08:13 +0530 Subject: [PATCH] feat: let the client be served from 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. Assets, fonts, config.json and the API were all absolute from /, so behind a shared domain at /experience every one of them 404d — and the config failure was silent, because an unreachable config.json falls back to the bundled defaults. Subdomains would avoid all of this, but a deployment with several apps behind one proxy cannot always have them, and a reference client should not require one. Assets are relative now (vite base: "./"), and the container rewrites from BASE_PATH at start-up, using the base image's envsubst template hook and sub_filter. One image serves from / or from /experience/ with no rebuild. CONFIG_URL and the API paths resolve against document.baseURI for the same reason. Two things this turned up that would each have looked like a different bug: - the router matched against the origin root, so every route 404d under a prefix while the page itself loaded. It now takes its basepath from the same - six icon paths in the header and recording controls were absolute strings, so only those images broke Verified both ways against the built image behind a second nginx acting as the edge proxy: at /experience/ and at /, every asset, font, config.json and the rendered page. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 8 ++++++- README.md | 24 +++++++++++++++++++ docker-compose.yml | 9 +++++++ index.html | 6 ++++- nginx.conf => nginx.conf.template | 16 +++++++++++++ src/bootstrap.tsx | 4 ++++ .../components/recording-controls.tsx | 8 +++---- .../screens-component/layouts/chat-header.tsx | 4 ++-- src/config/app-config.json | 6 ++--- src/lib/api-service.ts | 23 ++++++++++++------ src/lib/config/runtime-config.ts | 2 +- vite.config.ts | 4 ++++ 12 files changed, 95 insertions(+), 19 deletions(-) rename nginx.conf => nginx.conf.template (78%) diff --git a/Dockerfile b/Dockerfile index 0b3c15f..490a20f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,13 @@ RUN npm run build FROM nginx:alpine AS serve COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +# A template, not a config: the base image's entrypoint runs envsubst over +# /etc/nginx/templates and writes the result into conf.d before nginx starts. +# That is what substitutes BASE_PATH without needing a rebuild per deployment. +COPY nginx.conf.template /etc/nginx/templates/default.conf.template + +# Serve from the origin root unless a deployment says otherwise. +ENV BASE_PATH="" # The configuration a deployment overrides lives in its own directory, mounted # over by infrastructure. Moved out of the static root so there is exactly one diff --git a/README.md b/README.md index d6ba33e..3c5592d 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,30 @@ docker run -p 8080:8080 experience-ui `GET /healthz` returns `ok` for whatever is in front. +### Serving from a sub-path + +The client works from the origin root by default. To mount it under a path on a +shared domain, set `BASE_PATH` and have the proxy strip the prefix: + +```yaml +environment: + BASE_PATH: /experience +``` + +```nginx +location /experience/ { + proxy_pass http://experience-ui:8080/; # the trailing slash strips the prefix +} +``` + +Nothing is rebuilt. The container rewrites the `` in `index.html` at +start-up, and every URL the client builds — assets, fonts, `config.json` and the +API — is relative, so all of them follow it. + +Set the prefix **without** a trailing slash, and remember that nginx resolves a +`proxy_pass` hostname once at config load: restart the app container and the +proxy will keep the old address until it is reloaded too. + ### Configuring a deployment Everything a deployment changes — name, logo, favicon, colours, which features diff --git a/docker-compose.yml b/docker-compose.yml index 9b510c9..edb522e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,15 @@ services: image: experience-ui:latest ports: - "8080:8080" + environment: + # Where the app is mounted. Empty (the default) serves it from the origin + # root. Set it to the prefix without a trailing slash when it sits behind + # a path on a shared domain, and have the proxy strip that prefix: + # + # BASE_PATH: /experience + # location /experience/ { proxy_pass http://experience-ui:8080/; } + BASE_PATH: ${BASE_PATH:-} + restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/healthz || exit 1"] diff --git a/index.html b/index.html index 591cbfa..4c0aad9 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,11 @@ - + + + diff --git a/nginx.conf b/nginx.conf.template similarity index 78% rename from nginx.conf rename to nginx.conf.template index 96a2bdc..c78b16b 100644 --- a/nginx.conf +++ b/nginx.conf.template @@ -28,6 +28,22 @@ server { # bundles, so caching it pins the old ones. add_header Cache-Control $cache_control always; + # Where this app is mounted. Every URL the client builds — assets, + # config.json, the API — is relative and resolves against this, so the same + # image serves from / or from /experience/ with no rebuild. + # + # BASE_PATH is substituted by the base image's envsubst entrypoint. Empty + # (the default) leaves the app at the origin root. Set it to the prefix + # *without* a trailing slash: BASE_PATH=/experience + # + # The edge proxy is expected to strip the prefix before forwarding, so + # nothing below needs to know about it: + # + # location /experience/ { proxy_pass http://experience-ui:8080/; } + sub_filter '' ''; + sub_filter_once on; + sub_filter_types text/html; + add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https:; media-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index b2ee834..7f3f385 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -23,6 +23,10 @@ const routeProgress = createRouteProgress(); const router = createRouter({ routeTree, + // Where the app is mounted, from the the container injects. + // Without this the router matches paths against the origin root and every + // route 404s when the app is served from a sub-path. + basepath: new URL(document.baseURI).pathname, 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..6b8a642 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 = "assets/delete.svg"; +const micIcon = "assets/micIcon.svg"; +const sendRecord = "assets/sendRecord.svg"; +const pauseRecordIcon = "assets/pauseRecordIcon.svg"; type RecordingState = "recording" | "paused"; diff --git a/src/components/screens-component/layouts/chat-header.tsx b/src/components/screens-component/layouts/chat-header.tsx index 607a83e..c24de3a 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 = "assets/settings.svg"; +const langIcon = "assets/langIcon.svg"; export type ChatHeaderProps = { title: string; 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/api-service.ts b/src/lib/api-service.ts index b43ef3d..653f462 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -54,6 +54,15 @@ const stripSseKeepAliveMarkers = (chunk: string): string => .join('\n'); +/** + * Where the API lives, resolved against the document base. + * + * `environment.apiUrl` is empty, so the API is same-origin. Resolving against + * `document.baseURI` rather than `/` is what lets the app be served from a + * sub-path: mounted at /experience/, this becomes /experience/api/. + */ +const apiBase = new URL('api/', document.baseURI).pathname; + class ApiService { private apiUrl: string = environment.apiUrl; private locationData: LocationData | null = null; @@ -62,7 +71,7 @@ class ApiService { constructor() { this.axiosInstance = axios.create({ - baseURL: this.apiUrl, + baseURL: this.apiUrl || apiBase, headers: { 'Content-Type': 'application/json' } @@ -99,7 +108,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 +159,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 +202,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 +254,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 +286,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 +304,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/runtime-config.ts b/src/lib/config/runtime-config.ts index 7f2a465..cc6482a 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 = "config.json"; let active: AppConfig = bundledConfig; let loaded = false; diff --git a/vite.config.ts b/vite.config.ts index c9c614b..1425ecc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,10 @@ const appConfigPlugin: PluginOption = { // https://vite.dev/config/ export default defineConfig({ + // Relative, so every asset URL resolves against the the + // container injects. Without this they are absolute from / and the app + // only works when it owns the origin root. + base: "./", resolve: { alias: { "@": path.resolve(__dirname, "src"),