/** * Static-asset mount for the SPA bundle. * * The SPA bundle is produced by `6.5.2-cadre-host-local-ui-spa` into * `/dist/ui/`. When that directory exists we serve files from it * with an `index.html` SPA fallback. When it's missing (running from source * before the SPA ticket lands) we serve a placeholder page that explains * how to install/build the SPA. * * Hand-rolled rather than pulling in `@fastify/static` — the surface area we * need is tiny and the dep would only matter under tests that don't exercise * static serving anyway. */ import { readFileSync, statSync, existsSync } from 'node:fs'; import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', }; /** * Default location of the SPA bundle when `cadre-host` runs from its * installed package. * - dist/server/static.js → ../ui → packages/cadre-host/dist/ui */ export function defaultStaticRoot(): string { const here = dirname(fileURLToPath(import.meta.url)); return resolve(here, '..', 'ui'); } export interface StaticMountOptions { /** Absolute path to the SPA build output (defaults to `defaultStaticRoot()`). */ rootDir?: string; } export function registerStaticMount(app: FastifyInstance, opts: StaticMountOptions = {}): void { const rootDir = opts.rootDir ?? defaultStaticRoot(); // Register *after* all routes so API paths win. app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { if (request.method !== 'GET' && request.method !== 'HEAD') { return reply.code(404).send({ ok: false, error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` }, }); } // Don't try to serve API paths as static. if ( request.url.startsWith('/api') || request.url.startsWith('/nat') || request.url.startsWith('/update') ) { return reply.code(404).send({ ok: false, error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` }, }); } return serveStatic(rootDir, request, reply); }); } function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyReply): FastifyReply { if (!existsSync(rootDir)) { if (request.url === '/' || request.url === '/index.html') { return reply.code(200).type('text/html; charset=utf-8').send(placeholderHtml()); } return reply.code(404).send({ ok: false, error: { code: 'not_found', message: 'SPA bundle not installed' }, }); } const requested = stripQuery(request.url); const target = resolveSafe(rootDir, requested); if (!target) { return reply.code(404).send({ ok: false, error: { code: 'not_found', message: `No static asset for ${request.url}` }, }); } try { const stat = statSync(target); if (stat.isFile()) return sendFile(reply, target); } catch { // fall through to SPA index } // SPA fallback: serve index.html for any GET that accepts HTML. const accept = (request.headers.accept ?? '').toLowerCase(); if (accept.includes('text/html') || accept === '' || accept === '*/*') { const index = join(rootDir, 'index.html'); if (existsSync(index)) return sendFile(reply, index); } return reply.code(404).send({ ok: false, error: { code: 'not_found', message: `No static asset for ${request.url}` }, }); } /** * Resolve a request path to an absolute file path inside `rootDir`, or `null` * if it would escape the root. * * Exported for direct unit testing. The containment check uses `path.relative` * rather than a bare `startsWith` prefix test: the latter is the classic * prefix-bypass anti-pattern, accepting sibling directories whose name shares * `rootDir`'s basename as a prefix (e.g. `dist/ui-private` vs `dist/ui`). * `relative` respects the path-separator boundary on both POSIX and Windows, * and surfaces cross-drive escapes on Windows as an absolute result. */ export function resolveSafe(rootDir: string, urlPath: string): string | null { const cleaned = decodeSafe(urlPath); if (cleaned === null) return null; const root = normalize(rootDir); const target = normalize(join(root, cleaned === '/' ? '' : cleaned)); // Containment: target must be rootDir itself or strictly beneath it. // `relative(root, target)` is '' for root itself (accepted) and a descendant // path with no leading '..' for in-root assets. Reject when it climbs out // (exactly '..', or '..' + separator) or — cross-drive on Windows — is // absolute. Guarding on '..' + separator rather than a bare '..' prefix avoids // falsely rejecting legitimate in-root names that merely *begin* with '..' // (e.g. a '..foo' asset), which would re-introduce a looser prefix check. const rel = relative(root, target); if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return null; return target; } function decodeSafe(urlPath: string): string | null { try { const decoded = decodeURIComponent(urlPath); if (decoded.includes('\0')) return null; return decoded; } catch { return null; } } function stripQuery(url: string): string { const q = url.indexOf('?'); return q === -1 ? url : url.slice(0, q); } function sendFile(reply: FastifyReply, path: string): FastifyReply { const buf = readFileSync(path); const mime = MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'; return reply.code(200).type(mime).send(buf); } function placeholderHtml(): string { return ` cadre-host — UI bundle missing

cadre-host is running — UI bundle is missing

The HTTP management API is fully available (see endpoints below), but the Svelte SPA that normally renders at this URL hasn't been built into this install. This page is most likely shown because the package was started from a source checkout without running the UI build step.

To build it:

yarn workspace @serfab/cadre-host build

or just the UI:

yarn workspace @serfab/cadre-host build:ui

Then refresh this page.

Available API endpoints

See docs/cadre-host.md.

`; }