Files
ai-agent/frontend/vite.config.ts
Gabriel Vidal edabfc08a3 fix(pwa): load the web manifest behind Authelia
The browser fetches `<link rel="manifest">` anonymously unless told otherwise,
so Authelia answered it with the 302 to the portal and the cross-origin hop was
then blocked by CORS. The manifest failed to load on every single page view —
visible as an `ERR_FAILED` in the console — which is the file carrying the app
name, icons and theme colours used when installing the PWA.

`useCredentials: true` emits `crossorigin="use-credentials"`, so the request
carries the session cookie and is served as a normal authenticated 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:44:11 +02:00

102 lines
4.7 KiB
TypeScript

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import path from "node:path";
// Product branding, overridable at build time so the same bundle can ship as
// the homelab's viewer or as the public Tokunia demo
// (`VITE_APP_TITLE=… VITE_APP_SHORT_NAME=… npm run build`).
const TITLE = process.env.VITE_APP_TITLE || "Claude Agent — context";
// The manifest name has always been the longer form; a branded build overrides
// both with the same string.
const APP_NAME = process.env.VITE_APP_TITLE || "Claude Agent — context viewer";
const SHORT_NAME = process.env.VITE_APP_SHORT_NAME || "ai-agent";
const DESCRIPTION =
process.env.VITE_APP_DESCRIPTION ||
"Viewer/editor for the homelab's CLAUDE.md files and skills.";
export default defineConfig({
// Build-time id used to bust the persisted React Query (IndexedDB) cache on
// every deploy, so a changed API response shape can't resurrect stale entries.
define: { __BUILD_ID__: JSON.stringify(String(Date.now())) },
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: { host: true, port: 5180 },
plugins: [
react(),
// index.html ships the default <title>; a branded build rewrites it here so
// the served HTML and the manifest below always agree.
{
name: "app-title",
transformIndexHtml: (html) =>
html.replace(/<title>[^<]*<\/title>/, `<title>${TITLE}</title>`),
},
VitePWA({
// The generated worker calls skipWaiting()/clientsClaim(), so a new build
// always activates and takes over the open page's fetches. What it never
// does is reload that page — src/pwa.ts does, once it sees the handover.
registerType: "autoUpdate",
// We register the worker ourselves from src/pwa.ts so we can poll for
// updates; suppress the <script src="/registerSW.js"> the plugin injects.
injectRegister: false,
// Emit `crossorigin="use-credentials"` on <link rel="manifest">. The
// browser fetches the manifest anonymously by default, so behind Authelia
// it was answered with the 302 to the portal and then killed by CORS —
// the manifest never loaded at all, on any page view. Sending the session
// cookie with it makes the request a plain authenticated 200.
useCredentials: true,
includeAssets: ["icon.svg"],
manifest: {
name: APP_NAME,
short_name: SHORT_NAME,
description: DESCRIPTION,
theme_color: "#0a0a0a",
background_color: "#0a0a0a",
display: "standalone",
orientation: "portrait",
start_url: "/",
icons: [
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" },
],
},
workbox: {
// The plugin only implies these for `injectRegister: "auto"`, and we
// register the worker ourselves. Without them a new build installs but
// parks in "waiting" forever, and nothing ever serves the new assets.
skipWaiting: true,
clientsClaim: true,
navigateFallback: "/index.html",
// /avatars/* are self-contained static pages (sprite collections), not
// SPA routes — the worker must let their navigations hit the network.
// `?reauth=` is the re-authentication navigation src/api.ts makes when
// the Authelia session expires: it MUST reach Traefik to be answered
// with the 302 to the portal. Served from the precache instead, it
// reboots the logged-out shell and loops. Workbox tests these against
// `pathname + search`, so a query-string pattern is matched.
navigateFallbackDenylist: [/^\/api\//, /^\/avatars\//, /[?&]reauth=/],
// Drop precaches from previous builds so an upgrade can't serve a mix of
// old and new hashed chunks.
cleanupOutdatedCaches: true,
// Cache the API bundle so the shell still has data on a cold offline start.
// `/api/events` is exempt: it is an endless SSE stream, and NetworkFirst
// would try to read the whole body into the cache. That read never
// finishes, which keeps the old worker alive and wedges every future
// update in "waiting" forever. Let it go straight to the network.
runtimeCaching: [
{
urlPattern: ({ url }) =>
url.pathname.startsWith("/api/") && url.pathname !== "/api/events",
handler: "NetworkFirst",
options: {
cacheName: "ai-agent-api",
networkTimeoutSeconds: 5,
expiration: { maxEntries: 32, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
],
},
}),
],
});