Parcourir la source

feat(webui): serve correctly under a subpath mount

The build hardcoded a root mount: Vite emitted absolute /assets/... URLs,
the router was createBrowserRouter with no basename, and client.ts fetched
absolute /api/v1 and /ui/login. Served under nginx `location /rag/`, the
bundle 404'd and React never mounted - a blank page with an empty
<div id="root">.

Derive the mount at runtime instead:

- vite base './' so assets resolve relative to the document
- createHashRouter, which pins the document path to the mount point (a
  subpath is otherwise ambiguous: /rag/keys could be mount /rag route
  /keys, or mount / route /rag/keys)
- new api/base.ts: mountBase() strips the last path segment, returning ''
  at the root ('/' would make '//api/v1' protocol-relative) and '/rag'
  under a subpath
- every fetch and the /docs link go through serverUrl(); the 401 redirect
  sets location.hash rather than assigning a path

One build now works at / and at any subpath.
fszontagh il y a 1 mois
Parent
commit
ec02f6460b

+ 42 - 0
webui/src/api/base.ts

@@ -0,0 +1,42 @@
+/**
+ * Where this app is mounted on the origin.
+ *
+ * The same webui .deb is served two ways:
+ *   - at the server root     -> https://host/       (dev server, direct :8080)
+ *   - behind a subpath proxy -> https://host/rag/   (nginx strips /rag)
+ *
+ * The server can't tell us which, because the reverse proxy strips the prefix
+ * before the request arrives. But with hash routing the document path is always
+ * the mount point itself (the route lives in `location.hash`), so the prefix is
+ * recoverable from `location.pathname` alone.
+ *
+ * Every non-router URL must be built on top of this: API calls (`/api/v1/...`),
+ * the session endpoints (`/ui/login`), and the Redoc link (`/docs`).
+ */
+
+/**
+ * The mount prefix: either '' (mounted at the root) or a prefix with a leading
+ * but no trailing slash, so `mountBase() + '/api/v1/keys'` concatenates cleanly.
+ *
+ *   '/'               -> ''       (server root)
+ *   '/rag/'           -> '/rag'   (subpath, normal case)
+ *   '/rag/index.html' -> '/rag'   (explicit index)
+ *
+ * Dropping everything after the final '/' handles all three: under hash routing
+ * the path always names the directory the app was served from, optionally plus
+ * an index filename. Computed once — hash navigation never changes pathname.
+ *
+ * Returning '' rather than '/' for the root case matters: '/' would produce
+ * '//api/v1/...', which browsers read as protocol-relative and send to a host
+ * literally named 'api'.
+ */
+const BASE = location.pathname.replace(/\/[^/]*$/, '')
+
+export function mountBase(): string {
+  return BASE
+}
+
+/** Prefix a server-absolute path (must start with '/') with the mount base. */
+export function serverUrl(path: string): string {
+  return BASE + path
+}

+ 6 - 2
webui/src/api/client.ts

@@ -4,9 +4,12 @@ import type {
   KeyScope,
 } from '@/types'
 import { ApiError } from '@/types'
+import { serverUrl } from '@/api/base'
 
 async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
-  const res = await fetch(path, {
+  // `path` is server-absolute ('/api/v1/...'); serverUrl() adds the mount prefix
+  // so the same build works at '/' and under a subpath proxy like '/rag/'.
+  const res = await fetch(serverUrl(path), {
     ...options,
     credentials: 'include',
     headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
@@ -14,7 +17,8 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
   if (res.status === 401 && !path.endsWith('/ui/login')) {
     const { useAuthStore } = await import('@/stores/authStore')
     useAuthStore.getState().reset()
-    if (location.pathname !== '/login') location.assign('/login')
+    // Hash routing: the route lives in location.hash, not the path.
+    if (location.hash !== '#/login') location.hash = '#/login'
     throw new ApiError(401, 'unauthorized')
   }
   const text = await res.text()

+ 2 - 1
webui/src/components/AppShell.tsx

@@ -1,4 +1,5 @@
 import { useNavigate, NavLink, Outlet } from 'react-router-dom'
+import { serverUrl } from '@/api/base'
 import {
   LayoutDashboard,
   Database,
@@ -80,7 +81,7 @@ export function AppShell() {
           ))}
 
           <a
-            href="/docs"
+            href={serverUrl('/docs')}
             target="_blank"
             rel="noreferrer"
             className="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition text-slate-400 hover:text-slate-200 hover:bg-slate-700/50"

+ 4 - 2
webui/src/main.tsx

@@ -2,12 +2,14 @@ import './monacoSetup'
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { RouterProvider, createBrowserRouter } from 'react-router-dom'
+import { RouterProvider, createHashRouter } from 'react-router-dom'
 import { routes } from './App'
 import './index.css'
 
 const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })
-const router = createBrowserRouter(routes)
+// Hash routing keeps the document path pinned to the mount point, which is what
+// lets `mountBase()` recover the prefix when served under e.g. /rag/.
+const router = createHashRouter(routes)
 
 createRoot(document.getElementById('root')!).render(
   <StrictMode>

+ 4 - 0
webui/vite.config.ts

@@ -4,6 +4,10 @@ import tailwindcss from '@tailwindcss/vite'
 import path from 'path'
 
 export default defineConfig({
+  // Relative asset URLs so one build works both at the server root and behind a
+  // subpath reverse proxy (e.g. nginx `location /rag/`). Safe because the app
+  // uses hash routing: the document path is always the mount point itself.
+  base: './',
   plugins: [react(), tailwindcss()],
   resolve: { alias: { '@': path.resolve(__dirname, './src') } },
   server: {