import * as fs from 'node:fs' import * as path from 'node:path' import * as url from 'node:url' import * as zlib from 'node:zlib' import type { PluginOption } from 'vite' // Vite plugin that turns the TanStack Start SPA build output (`dist/client`) // into the shipped asset map `src/admin/ui.generated.ts` — the only thing the // Hono admin app imports from the UI. The library `tsc`/`zile` build never sees // the `.tsx` source (just this generated module), so the published bundle gains // no React or TanStack runtime dependency. // // It hooks `buildApp` with `order: 'post'` because TanStack Start writes the // prerendered `_shell.html` from its own `buildApp` post handler — which runs // *after* every environment's `closeBundle`. Registering this plugin after // `tanstackStart()` makes this handler run last, once the shell exists. const here = path.dirname(url.fileURLToPath(import.meta.url)) const clientDir = path.join(here, 'dist', 'client') const outFile = path.join(here, '..', 'ui.generated.ts') // Total gzipped budget for the whole UI; the React + TanStack + Regen baseline // is heavier than a hand-rolled SPA, so fail loudly before it bloats the // package. Raise deliberately (and revisit code-splitting) if it trips. // Raised from 415 KB for the structured Earn-vault editor. const maxGzipBytes = 430_000 const contentTypes: Record = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', } // Text assets inline as UTF-8; anything else (fonts, images) base64-encodes. const textExtensions = new Set(['.css', '.html', '.js', '.json', '.map', '.svg']) type Asset = { content: string; contentType: string; encoding?: 'base64' } function walk(dir: string): readonly string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name) return entry.isDirectory() ? walk(full) : [full] }) } function emitAssetMap(): void { if (!fs.existsSync(clientDir)) throw new Error(`Missing ${clientDir}; the SPA client build did not run.`) const shellPath = path.join(clientDir, '_shell.html') if (!fs.existsSync(shellPath)) throw new Error(`Missing SPA shell at ${shellPath}.`) // TanStack Start emits absolute `/./assets/...` URLs for the entry script and // modulepreloads (CSS is already relative). Normalize to `./assets/...` so they // resolve against the `` the Hono app injects when serving the shell // — that's what makes the single artifact work path-mounted (e.g. `/admin`). const shell = fs.readFileSync(shellPath, 'utf8').replaceAll('/./assets/', './assets/') const assets: Record = {} let totalGzip = 0 for (const file of walk(clientDir)) { if (file === shellPath) continue const key = path.relative(clientDir, file).split(path.sep).join('/') const ext = path.extname(file) const buffer = fs.readFileSync(file) totalGzip += zlib.gzipSync(buffer).length assets[key] = { content: textExtensions.has(ext) ? buffer.toString('utf8') : buffer.toString('base64'), contentType: contentTypes[ext] ?? 'application/octet-stream', ...(textExtensions.has(ext) ? {} : { encoding: 'base64' as const }), } } totalGzip += zlib.gzipSync(Buffer.from(shell, 'utf8')).length if (totalGzip > maxGzipBytes) throw new Error( `Admin UI bundle is ${(totalGzip / 1000).toFixed(0)} KB gzip, over the ${maxGzipBytes / 1000} KB budget.`, ) const sortedAssets = Object.fromEntries( Object.entries(assets).sort(([a], [b]) => a.localeCompare(b)), ) const banner = `// Generated by src/admin/ui/plugin.ts — do not edit by hand. // Run \`pnpm build:ui\` to regenerate from the TanStack Start SPA build. /* eslint-disable */` const body = `${banner} /** A single built UI file: text inline as UTF-8, binary as base64. */ export type Asset = { content: string contentType: string encoding?: 'base64' } /** The prerendered SPA shell HTML (assets normalized to relative URLs). */ export const shell = ${JSON.stringify(shell)} /** Content-hashed UI assets, keyed by their path under the mount (e.g. \`assets/index-*.js\`). */ export const assets: Record = ${JSON.stringify(sortedAssets, null, 2)} ` fs.writeFileSync(outFile, body) console.log( `Wrote ${path.relative(path.join(here, '..', '..', '..'), outFile)} (${Object.keys(assets).length} assets, ${(totalGzip / 1000).toFixed(0)} KB gzip).`, ) } /** * Emits `src/admin/ui.generated.ts` from the SPA build output. Register *after* * `tanstackStart()` so its `buildApp` post handler (which writes `_shell.html` * via prerender) runs first. */ export function generated(): PluginOption { return { name: 'tempo-api-admin-ui-generated', enforce: 'post', buildApp: { order: 'post', async handler() { emitAssetMap() process.exit(0) }, }, } }