Files
rustingface/web/vite.config.ts
rob thijssen acd47a31c8 feat(web): add the operator UI and the /v1 inventory API it reads
Three routes, per architecture/generic.md §4 (Vite + React + SWC + TS,
static, served by nginx): / renders the repository readme, /models is a
paged and filterable listing of what the bucket holds, and
/{namespace}/{name} shows one repository -- its pinned refs, the files
actually stored, and its model card.

The API it reads is /v1/, deliberately not /api/. That surface is the
Hub's, recorded and replayed verbatim, and adding routes of our own to
it risks a client mistaking one for the real thing. /v1/ answers a
question the Hub has no equivalent for -- what is in this bucket --
which is inventory, not the model search the spec rules out.

The listing shows only what has actually been fetched, never upstream's
siblings. A repository pulled one file at a time shows one file, which
is the honest answer to "what can I get from here offline".

Model cards are third-party content, so their HTML is parsed and then
sanitised against GitHub's allowlist. The plugin order is load-bearing
and commented as such. Verified against a card crafted with <script>,
onerror, a javascript: href, an <iframe>, an SVG-embedded script and an
inline handler: none execute and ordinary markdown still renders. Card
images are not loaded at all -- fetching them would leak the viewer's
address to a third party and make an offline registry's pages depend on
the internet.

Routing: rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is
both a page and the prefix of a file. The vhost splits them the way the
Hub does -- /resolve/ anywhere in the path, plus /api/ and /v1/, go to
the service; everything else is the app. A repo legitimately named
v1/repos is handed back to the resolve path by the router, and there is
a test for it. The /resolve/ test is on the repo type rather than the
path substring, because a repo may contain a directory called resolve
and /api/models/a/b/tree/main/resolve/f must stay a tree request.

Deployment: the frontend ships to hanzalova:/var/www/rustingface, so
that host now gets its own scoped gitea_ci drop-in -- narrower than the
service host's: a webroot rsync, a relabel, nginx -t and a reload. The
health check probes rf.internal from the proxy rather than from the
runner, because a runner is a plain Fedora container with no internal
root CA (verified: fedora:43 gets 000, the proxy gets 200).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 12:37:26 +03:00

63 lines
2.2 KiB
TypeScript

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
const devApi = process.env.VITE_DEV_API ?? 'http://127.0.0.1:20482'
/**
* Expose the repository readme to the app as a virtual module.
*
* The landing page is the readme, so that there is exactly one description of
* what this service is: a copy under `web/` would drift from the real one, and
* the drift would be invisible until somebody noticed the site disagreeing
* with the repository.
*/
function repoReadme() {
const id = 'virtual:repo-readme'
const resolved = '\0' + id
return {
name: 'repo-readme',
resolveId: (source: string) => (source === id ? resolved : null),
load(this: { addWatchFile?: (f: string) => void }, requested: string) {
if (requested !== resolved) return null
const path = repoRoot + 'readme.md'
this.addWatchFile?.(path)
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
},
}
}
export default defineConfig({
plugins: [react(), repoReadme()],
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
// Split the dependencies that never change from the app that does, so
// a redeploy does not invalidate a viewer's cached copy of React and
// the markdown stack.
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
markdown: ['react-markdown', 'remark-gfm', 'rehype-raw', 'rehype-sanitize'],
},
},
},
},
server: {
// Dev server only: proxy to a running rustingface so the app is exercised
// against the real thing rather than a fixture. Mirrors what the nginx
// vhost does in production — including the resolve paths, which is how
// model cards are read.
proxy: {
'/v1': { target: devApi, changeOrigin: true },
'/api': { target: devApi, changeOrigin: true },
'/healthz': { target: devApi, changeOrigin: true },
// `/{namespace}/{name}/resolve/...` and its bare-name form.
'^/[^/]+/(?:[^/]+/)?resolve/': { target: devApi, changeOrigin: true },
},
},
})