diff --git a/README.md b/README.md index 16855209..74acdd16 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ HAMi-WebUI is built upon the [HAMi](https://github.com/Project-HAMi/HAMi) open-s ## Get started - [Installation guide](docs/installation/helm/index.md) +- [Serving under a URL sub-path (base path)](docs/installation/sub-path.md) ## Contributing diff --git a/charts/hami-webui/README.md b/charts/hami-webui/README.md index 6c90ec02..c054819a 100644 --- a/charts/hami-webui/README.md +++ b/charts/hami-webui/README.md @@ -47,6 +47,7 @@ The command removes all the Kubernetes components associated with the chart and | Key | Type | Default | Description | |-----|------|------------------------------------------------------------------------------------|-------------| | affinity | object | `{}` | | +| basePath | string | `"/"` | URL sub-path to serve the WebUI under, injected as `HAMI_WEBUI_BASE_PATH`. `"/"` = root (default). Set e.g. `"/gpu-ui/"` for a non-stripping reverse proxy. Resolved at request time (no image rebuild). A path-stripping proxy that sets `X-Forwarded-Prefix` works without this. | | dcgm-exporter.enabled | bool | `true` | | | dcgm-exporter.nodeSelector.gpu | string | `"on"` | | | dcgm-exporter.serviceMonitor.additionalLabels.jobRelease | string | `"hami-webui-prometheus"` | | @@ -163,7 +164,46 @@ kube-prometheus-stack: ``` This allows you to reuse the existing Operator and CRDs while deploying a new Prometheus instance. -### 3. About `jobRelease` Labels +### 3. Serving under a URL sub-path (base path) + +By default the WebUI is served at the site root (`/`). To serve it behind a +reverse-proxy prefix such as `https://host/gpu-ui/` — without rebuilding the +frontend image — set the base path. It is resolved at **request time**, so the +same image works at any path. + +There are two supported reverse-proxy modes: + +**Mode A — path-stripping proxy (recommended, zero chart config).** +If your proxy strips the prefix before forwarding and sets the +`X-Forwarded-Prefix` header (nginx `proxy_set_header X-Forwarded-Prefix /gpu-ui;`, +Traefik `stripPrefix` + headers middleware, etc.), the WebUI picks the prefix up +from the header automatically. Leave `basePath` at its default `/`. + +**Mode B — proxy passes the full prefixed path through (no stripping).** +Set the chart value so the BFF knows its own prefix: + +```yaml +basePath: "/gpu-ui/" +# If you also expose it through this chart's ingress, point the path at the same prefix: +ingress: + enabled: true + hosts: + - host: your-host + paths: + - path: /gpu-ui + pathType: Prefix +``` + +`basePath` is injected into the frontend BFF container as the +`HAMI_WEBUI_BASE_PATH` environment variable. Values are normalized to a +leading/trailing-slash form (`gpu-ui` → `/gpu-ui/`); `/` (the default) means +root serving and preserves the historical behaviour exactly. The header (Mode A) +takes precedence over the env var when both are present. + +No changes are needed on the Go API backend — it is always reached via the BFF's +`/api/vgpu` proxy. + +### 4. About `jobRelease` Labels If deploying a completely new Prometheus, you can leave the default `jobRelease: hami-webui-prometheus` unchanged. diff --git a/charts/hami-webui/templates/deployment.yaml b/charts/hami-webui/templates/deployment.yaml index 83219b90..65796339 100644 --- a/charts/hami-webui/templates/deployment.yaml +++ b/charts/hami-webui/templates/deployment.yaml @@ -33,6 +33,8 @@ spec: imagePullPolicy: {{ .Values.image.frontend.pullPolicy }} env: {{- toYaml .Values.env.frontend | nindent 12 }} + - name: HAMI_WEBUI_BASE_PATH + value: {{ .Values.basePath | default "/" | quote }} ports: - name: http containerPort: 3000 diff --git a/charts/hami-webui/values.yaml b/charts/hami-webui/values.yaml index e7c93b43..c0f244b9 100644 --- a/charts/hami-webui/values.yaml +++ b/charts/hami-webui/values.yaml @@ -49,6 +49,17 @@ securityContext: {} # runAsNonRoot: true # runAsUser: 1000 +# URL sub-path (base path) the WebUI is served under. "/" (default) serves at +# the site root and preserves the historical behaviour exactly. Set to a prefix +# such as "/gpu-ui/" to serve the WebUI behind a reverse proxy that passes the +# full prefixed path through (i.e. does NOT strip it). It is injected into the +# frontend BFF as the HAMI_WEBUI_BASE_PATH env var and resolved at request time, +# so changing it does NOT require rebuilding the frontend image. A path-stripping +# proxy that sets the X-Forwarded-Prefix header works without setting this. +# If you set a non-root basePath, remember to update ingress.hosts[].paths[].path +# to the same prefix. +basePath: "/" + service: type: ClusterIP port: 3000 diff --git a/docs/installation/sub-path.md b/docs/installation/sub-path.md new file mode 100644 index 00000000..8cbae19b --- /dev/null +++ b/docs/installation/sub-path.md @@ -0,0 +1,141 @@ +# Serving HAMi-WebUI under a URL sub-path + +By default HAMi-WebUI is served at the site root (`https://host/`). It can also +be served under an arbitrary reverse-proxy prefix (a "sub-path" / "base path"), +e.g. `https://host/gpu-ui/`, so several tools can share one hostname. + +The base path is resolved **at request time**, not baked into the frontend +bundle — so a single `*-fe-oss` image works at any path and changing the path +never requires an image rebuild. The design mirrors Grafana's +`serve_from_sub_path` and ArgoCD's `server.rootpath`. + +The Go API backend (`*-be-oss`) is unaffected — it is always reached through the +BFF's `/api/vgpu` proxy and is path-agnostic. + +## How the base path is resolved + +The frontend BFF (the NestJS `*-fe-oss` container) resolves the prefix from two +sources, in order of precedence: + +1. **`X-Forwarded-Prefix` request header** — set by a path-stripping reverse + proxy. Takes precedence when present. +2. **`HAMI_WEBUI_BASE_PATH` environment variable** — the deploy-time default. + Defaults to `/` (root serving, the historical behaviour). + +Values are normalized to a leading/trailing-slash form: `gpu-ui`, `/gpu-ui`, and +`/gpu-ui/` all become `/gpu-ui/`. `/` means root. + +On every request for `index.html` the BFF injects the resolved value as: + +```html + + +``` + +The SPA reads `window.__BASE_PATH__` for its router base, its axios `baseURL`, +and its socket.io `path`; the `` tag makes the relative asset URLs +(`./static/…`, `./favicon.svg`) resolve under the prefix. + +## Two supported proxy modes + +### Mode A — path-stripping proxy (sets `X-Forwarded-Prefix`) + +The proxy strips the prefix before forwarding to the BFF and advertises it via +the header. Nothing needs to be configured on the chart — leave `basePath` at +`/`. + +nginx example: + +```nginx +location /gpu-ui/ { + proxy_pass http://hami-webui:3000/; # trailing slash strips /gpu-ui/ + proxy_set_header X-Forwarded-Prefix /gpu-ui; + proxy_set_header Host $host; + # socket.io upgrade (optional, for real-time views) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +### Mode B — proxy forwards the full prefixed path (no stripping) + +The BFF receives `/gpu-ui/...` and must know its own prefix, so set the chart +value (or the env var directly): + +```yaml +# values.yaml +basePath: "/gpu-ui/" +ingress: + enabled: true + hosts: + - host: your-host + paths: + - path: /gpu-ui + pathType: Prefix +``` + +The BFF strips the configured prefix from incoming URLs up-front, so static +assets, the `/api/vgpu` proxy and the SPA deep-link fallback all keep working. + +## Helm value + +| Value | Default | Description | +|------------|---------|-----------------------------------------------------------------------------| +| `basePath` | `"/"` | Sub-path to serve under. Injected as `HAMI_WEBUI_BASE_PATH`. `"/"` = root. | + +--- + +## Manual test plan + +Prerequisites: a running cluster (or local `node dist/main`) with the built +frontend in `public/`. Replace `HOST` accordingly. + +### 1. Root serving — no regression (default) + +Deploy with defaults (`basePath: "/"`, no header). + +| Check | Expect | +|-------|--------| +| `GET /` | 200; HTML contains `` and `window.__BASE_PATH__="/"` | +| Deep-link refresh `GET /admin/vgpu/monitor/overview` | 200; same injected `` | +| `GET /static/.js` | 200 | +| `GET /favicon.svg` | 200 | +| `GET /api/vgpu/v1/summary` (POST from UI) | proxied to the Go backend | +| `GET /health_check` | `{"code":0,...,"data":"OK"}` | +| Browser: open `/`, navigate the app, open a real-time view | assets load, REST works, socket.io connects on `/socket.io` | + +### 2. Mode B — non-stripping proxy under `/gpu-ui/` + +Deploy with `basePath: "/gpu-ui/"` and an ingress path of `/gpu-ui`. + +| Check | Expect | +|-------|--------| +| `GET /gpu-ui/` and `GET /gpu-ui` | 200; ``, `window.__BASE_PATH__="/gpu-ui/"` | +| Deep-link refresh `GET /gpu-ui/admin/vgpu/monitor/overview` | 200; injected base `/gpu-ui/` | +| `GET /gpu-ui/static/.js` | 200 | +| `GET /gpu-ui/favicon.svg` | 200 | +| UI REST calls | requested at `/gpu-ui/api/vgpu/...`, proxied to the backend | +| Real-time view | socket.io connects at `/gpu-ui/socket.io` | +| `GET /health_check` (unprefixed k8s probe) | still `...,"data":"OK"` | + +### 3. Mode A — path-stripping proxy via `X-Forwarded-Prefix` + +Deploy with defaults (`basePath: "/"`) behind a proxy that strips `/gpu-ui` and +sets `X-Forwarded-Prefix: /gpu-ui`. + +| Check | Expect | +|-------|--------| +| `curl -H 'X-Forwarded-Prefix: /gpu-ui' https://HOST/` | ``, `window.__BASE_PATH__="/gpu-ui/"` | +| Deep-link (proxy has already stripped path) `curl -H 'X-Forwarded-Prefix: /gpu-ui' https://HOST/admin/...` | injected base `/gpu-ui/` | +| Through the browser at `https://HOST/gpu-ui/` | page loads, assets/REST/socket.io all resolve under `/gpu-ui/` | + +### 4. Security + +| Check | Expect | +|-------|--------| +| `curl -H 'X-Forwarded-Prefix: /x">' https://HOST/` | injected `` is sanitized (no `"`, `<`, `>`); no script injection | + +A scripted version of checks 1–3 (and 4) is exercised by the BFF unit tests in +`src/utils/base-path.spec.ts` and by running `node dist/main` with +`HAMI_WEBUI_BASE_PATH` / `X-Forwarded-Prefix` against a `public/` build. diff --git a/packages/web/index.html b/packages/web/index.html index 4bc0a372..42d54936 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,8 +4,13 @@ - - + + HAMi @@ -14,6 +19,6 @@
- + diff --git a/packages/web/src/components/Socket/index.js b/packages/web/src/components/Socket/index.js index f66975b7..f8a3ded3 100644 --- a/packages/web/src/components/Socket/index.js +++ b/packages/web/src/components/Socket/index.js @@ -1,11 +1,16 @@ import { io } from 'socket.io-client'; +import { getBasePath } from '@/utils/base-path'; class WS { static instance; socket; constructor(url) { - this.socket = io(url); + // socket.io's default endpoint is the root-absolute "/socket.io". Under a + // sub-path the reverse proxy forwards "{basePath}socket.io", so set the + // client path accordingly. At the site root this is exactly "/socket.io". + const path = `${getBasePath()}socket.io`; + this.socket = io(url, { path }); this.socket.on('connect', () => { }); diff --git a/packages/web/src/router/index.js b/packages/web/src/router/index.js index b78b5187..7b500317 100644 --- a/packages/web/src/router/index.js +++ b/packages/web/src/router/index.js @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router'; import { pick, uniqBy } from 'lodash'; +import { getBasePath } from '@/utils/base-path'; /* Layout */ import Layout from '@/layout'; /* Router Modules */ @@ -50,7 +51,9 @@ export const asyncRoutes = [ ]; const router = createRouter({ - history: createWebHistory(), + // Base the HTML5 history on the runtime-injected sub-path so deep-link + // refreshes resolve under the prefix (defaults to "/" for root serving). + history: createWebHistory(getBasePath()), routes: constantRoutes, }); diff --git a/packages/web/src/utils/base-path.js b/packages/web/src/utils/base-path.js new file mode 100644 index 00000000..66342d59 --- /dev/null +++ b/packages/web/src/utils/base-path.js @@ -0,0 +1,25 @@ +/** + * Runtime URL sub-path (base path) helpers for the SPA. + * + * The BFF injects `window.__BASE_PATH__` into index.html at request time + * (e.g. "/" for root serving, "/gpu-ui/" behind a reverse-proxy prefix). + * Everything that builds an absolute-ish URL — the vue-router history base, + * the axios baseURL, the socket.io path — derives it from here so a single + * frontend build works under any prefix without a rebuild. + * + * In the Vite dev server (where the BFF does not serve index.html) the global + * is undefined and we fall back to root, matching the dev proxy setup. + */ + +/** Canonical base path with leading + trailing slash, e.g. "/" or "/gpu-ui/". */ +export function getBasePath() { + const runtime = + typeof window !== 'undefined' ? window.__BASE_PATH__ : undefined; + if (!runtime) { + return '/'; + } + let p = String(runtime).trim(); + if (!p.startsWith('/')) p = '/' + p; + if (!p.endsWith('/')) p = p + '/'; + return p; +} diff --git a/packages/web/src/utils/request.js b/packages/web/src/utils/request.js index bd62594a..8dce1965 100644 --- a/packages/web/src/utils/request.js +++ b/packages/web/src/utils/request.js @@ -2,6 +2,7 @@ import axios from 'axios'; import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'; import i18n from '@/locales'; +import { getBasePath } from '@/utils/base-path'; // Default request timeout in ms. Override at build time via VUE_APP_REQUEST_TIMEOUT // (injected through .env.* or chart values.frontend.requestTimeout). 60s is large @@ -11,8 +12,14 @@ const DEFAULT_REQUEST_TIMEOUT = 60000; const requestTimeout = Number.parseInt(process.env.VUE_APP_REQUEST_TIMEOUT, 10) || DEFAULT_REQUEST_TIMEOUT; +// API request URLs are root-absolute ("/api/vgpu/..."). Using the runtime base +// path as the axios baseURL makes them resolve under the sub-path (e.g. +// "/gpu-ui/api/vgpu/...") without depending on the injected tag. At the +// site root — and in the Vite dev server, where window.__BASE_PATH__ is not +// injected — getBasePath() returns "/", giving the historical "/api/vgpu/..." +// exactly (superseding the build-time VUE_APP_BASE_API). const service = axios.create({ - baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url + baseURL: getBasePath(), // url = base url + request url timeout: requestTimeout, validateStatus: function (status) { return (status >= 200 && status < 300) || status > 520; diff --git a/src/app.controller.ts b/src/app.controller.ts index 9ab05301..b46986e3 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -2,11 +2,19 @@ import { Controller, Get, Req, Res } from '@nestjs/common' import { AppService } from './app.service' import { Request, Response } from 'express' import { join } from 'path' +import { readFileSync } from 'fs' +import { injectBasePath, resolveBasePath } from './utils/base-path' @Controller() export class AppController { constructor(private readonly appService: AppService) {} + // index.html is read from disk once and cached; the per-request base path is + // injected on every send, so the same built artifact serves any sub-path. + private indexTemplate: string | null = null + + private readonly indexPath = join(__dirname, '..', 'public', 'index.html') + @Get('health_check') healthCheck(): string { return this.appService.healthCheck() @@ -15,6 +23,21 @@ export class AppController { // api 透传到后api-proxy,health_check, bff 被node接管,其他的直接打回前端vue路由 @Get('*') index(@Req() req: Request, @Res() res: Response) { - return res.sendFile(join(__dirname, '..', 'public', 'index.html')) + const basePath = resolveBasePath(req) + // The injected /window.__BASE_PATH__ depend on X-Forwarded-Prefix, + // so the response body varies by that header. Advertise it via Vary so any + // CDN/intermediate proxy caches responses per-prefix instead of serving one + // prefix's index.html to everyone (cache-poisoning / broken-routing risk). + res.setHeader('Vary', 'X-Forwarded-Prefix') + res.type('html').send(this.renderIndex(basePath)) + } + + // Return index.html with the resolved base path injected at request time + // (see injectBasePath). The template is read from disk once and cached. + private renderIndex(basePath: string): string { + if (this.indexTemplate === null) { + this.indexTemplate = readFileSync(this.indexPath, 'utf-8') + } + return injectBasePath(this.indexTemplate, basePath) } } diff --git a/src/main.ts b/src/main.ts index 7eed9dfe..a02cd424 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,16 +2,47 @@ import { NestFactory } from '@nestjs/core' import { NestExpressApplication } from '@nestjs/platform-express' import { AppModule } from './app.module' import { join } from 'path' +import { Request, Response, NextFunction } from 'express' import { TransformInterceptor } from './interceptors/transform.interceptor' import * as cookieParser from 'cookie-parser' +import { ENV_BASE_PATH } from './utils/base-path' async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false }) + // Sub-path support (see src/utils/base-path.ts). When the WebUI is served + // under a non-root prefix by an ingress that passes the full prefixed path + // through (i.e. does NOT strip it), incoming URLs look like `/gpu-ui/api/...` + // or `/gpu-ui/static/...`. Strip the configured prefix up-front so that all + // downstream routing — static assets, the /api* proxy, and the SPA fallback — + // keeps matching root-relative paths exactly as it does at the site root. + // A path-stripping proxy (which instead sets X-Forwarded-Prefix) already + // delivers root-relative URLs, so this middleware is a no-op there. + if (ENV_BASE_PATH !== '/') { + const bare = ENV_BASE_PATH.replace(/\/$/, '') // e.g. "/gpu-ui" + app.use((req: Request, _res: Response, next: NextFunction) => { + if (req.url === bare) { + req.url = '/' + } else if (req.url.startsWith(bare + '/')) { + req.url = req.url.slice(bare.length) || '/' + } + next() + }) + } + // 指定静态资源目录,这里放的是package下构建的文件 - app.useStaticAssets(join(__dirname, '..', 'public')) + // `index: false` disables serve-static's implicit index.html handling so that + // requests for `/` fall through to AppController, which injects the runtime + // base path into index.html instead of serving it verbatim. `redirect: false` + // stops serve-static from 301-redirecting a bare directory request (e.g. + // `/gpu-ui`, which the prefix-strip middleware above rewrites to `/`) to a + // trailing-slash URL — the SPA fallback serves it directly instead. + app.useStaticAssets(join(__dirname, '..', 'public'), { + index: false, + redirect: false + }) app.setBaseViewsDir(join(__dirname, '..', 'public')) app.setViewEngine('hbs') @@ -22,8 +53,8 @@ async function bootstrap() { // 统一后端bff层接口返回的格式 app.useGlobalInterceptors(new TransformInterceptor()) - // 监听3000 - await app.listen(3000) + // 监听3000 (overridable via PORT for local testing; container listens on 3000) + await app.listen(process.env.PORT || 3000) } bootstrap() diff --git a/src/utils/base-path.spec.ts b/src/utils/base-path.spec.ts new file mode 100644 index 00000000..ac901e3c --- /dev/null +++ b/src/utils/base-path.spec.ts @@ -0,0 +1,74 @@ +import { Request } from 'express' +import { injectBasePath, normalizeBasePath, resolveBasePath } from './base-path' + +describe('normalizeBasePath', () => { + it('defaults empty / missing / root inputs to "/"', () => { + expect(normalizeBasePath(undefined)).toBe('/') + expect(normalizeBasePath('')).toBe('/') + expect(normalizeBasePath(' ')).toBe('/') + expect(normalizeBasePath('/')).toBe('/') + }) + + it('adds a leading and trailing slash', () => { + expect(normalizeBasePath('gpu-ui')).toBe('/gpu-ui/') + expect(normalizeBasePath('/gpu-ui')).toBe('/gpu-ui/') + expect(normalizeBasePath('gpu-ui/')).toBe('/gpu-ui/') + expect(normalizeBasePath('/gpu-ui/')).toBe('/gpu-ui/') + expect(normalizeBasePath('/a/b/c')).toBe('/a/b/c/') + }) + + it('trims whitespace and collapses duplicate slashes', () => { + expect(normalizeBasePath(' /gpu-ui ')).toBe('/gpu-ui/') + expect(normalizeBasePath('//gpu-ui//')).toBe('/gpu-ui/') + }) + + it('takes the first value of an array header', () => { + expect(normalizeBasePath(['/hdr', '/other'])).toBe('/hdr/') + }) + + it('strips characters that could break out of the HTML/JS context', () => { + const out = normalizeBasePath('/gpu-ui">') + expect(out).not.toMatch(/["'<>]/) + }) +}) + +describe('resolveBasePath', () => { + const req = (headers: Record) => + ({ headers } as unknown as Request) + + it('prefers the X-Forwarded-Prefix header when present', () => { + expect(resolveBasePath(req({ 'x-forwarded-prefix': '/gpu-ui' }))).toBe( + '/gpu-ui/' + ) + }) + + it('falls back to the environment default (root by default)', () => { + expect(resolveBasePath(req({}))).toBe('/') + }) +}) + +describe('injectBasePath', () => { + const html = + 'HAMi' + + it('injects and window.__BASE_PATH__ for a sub-path', () => { + const out = injectBasePath(html, '/gpu-ui/') + expect(out).toContain('') + expect(out).toContain('window.__BASE_PATH__="/gpu-ui/"') + // the original hard-coded root must be gone + expect(out).not.toContain('') + }) + + it('is idempotent-safe at the site root', () => { + const out = injectBasePath(html, '/') + expect(out).toContain('') + expect(out).toContain('window.__BASE_PATH__="/"') + }) + + it('injects immediately after so the base precedes asset refs', () => { + const out = injectBasePath(html, '/gpu-ui/') + expect(out.indexOf('')).toBeLessThan( + out.indexOf('') + ) + }) +}) diff --git a/src/utils/base-path.ts b/src/utils/base-path.ts new file mode 100644 index 00000000..be367880 --- /dev/null +++ b/src/utils/base-path.ts @@ -0,0 +1,85 @@ +import { Request } from 'express' + +/** + * URL sub-path (base path) support for the BFF. + * + * The WebUI can be served either at the site root (`/`, the default and + * historical behaviour) or under an arbitrary reverse-proxy prefix such as + * `/gpu-ui/`. The prefix is resolved at request time from two sources, in + * order of precedence: + * + * 1. the `X-Forwarded-Prefix` request header — set by a path-stripping + * reverse proxy, so a deployment behind such a proxy "just works"; + * 2. the `HAMI_WEBUI_BASE_PATH` environment variable — the deploy-time + * default, used when the proxy passes the full prefixed path through. + * + * Modelled on Grafana's `serve_from_sub_path` and ArgoCD's `server.rootpath`. + */ + +/** + * Normalize a raw base path into a canonical `/segment/.../` form with a + * leading and trailing slash. Returns `/` for empty / root inputs. + * + * The value ends up in an HTML `<base href>` attribute and a `window.__BASE_PATH__` + * assignment, and is also compared against request URLs, so we strip anything + * outside a conservative URL-path character set to avoid HTML/JS injection. + */ +export function normalizeBasePath(raw?: string | string[]): string { + if (!raw) { + return '/' + } + let p = Array.isArray(raw) ? raw[0] : raw + p = String(p).trim() + // Keep only safe URL-path characters (drops quotes, angle brackets, spaces…). + p = p.replace(/[^A-Za-z0-9\-._~/%]/g, '') + // Collapse any run of slashes into a single one. + p = p.replace(/\/{2,}/g, '/') + if (p === '' || p === '/') { + return '/' + } + if (!p.startsWith('/')) { + p = '/' + p + } + if (!p.endsWith('/')) { + p = p + '/' + } + return p +} + +/** + * The deploy-time default base path, read once from the environment. + * `/` (root) is the default and preserves the historical behaviour exactly. + */ +export const ENV_BASE_PATH = normalizeBasePath(process.env.HAMI_WEBUI_BASE_PATH) + +/** + * Resolve the effective base path for a single request. The + * `X-Forwarded-Prefix` header (set by a path-stripping proxy) takes precedence + * over the environment default when present. + */ +export function resolveBasePath(req: Request): string { + const header = req.headers['x-forwarded-prefix'] + if (header) { + return normalizeBasePath(header) + } + return ENV_BASE_PATH +} + +/** + * Inject the resolved base path into an index.html string at request time: + * - a `<base href="{basePath}">` tag so all relative asset/API URLs (the build + * uses a relative `./` base) resolve under the prefix; + * - a `window.__BASE_PATH__` global the SPA reads for its router base, axios + * baseURL and socket.io path. + * Any pre-existing `<base>` tag is removed first so nothing hard-codes root + * serving. `basePath` is expected to already be normalized (and thus safe to + * interpolate — see normalizeBasePath). + */ +export function injectBasePath(html: string, basePath: string): string { + const inject = + `<base href="${basePath}">` + + `<script>window.__BASE_PATH__=${JSON.stringify(basePath)}</script>` + return html + .replace(/<base\b[^>]*>/i, '') + .replace(/<head\b[^>]*>/i, (head) => `${head}${inject}`) +}