@mednours/fronton
v0.3.0
Published
Opinionated runtime pieces for fronton-scaffolded Vite projects — Tailwind class obfuscation, gzip/brotli compression, and backon API wiring.
Readme
@mednours/fronton
Vite plugins and runtime helpers for a fronton-scaffolded project — installed automatically by create-fronton, or add by hand to an existing Vite project.
bun add @mednours/frontonTwo separate entry points, deliberately not one:
@mednours/fronton(the root import) — Node-side, meant forvite.config.tsonly:obfuscateTailwind(),frontonCompression(),backonProxy().@mednours/fronton/client— browser-safe, meant for application code:resolveApiBaseUrl(). Nothing here has a peer dependency, so importing it never obliges a project to install anything.@mednours/fronton/motion—lazyDomAnimation()/lazyDomMax(), which need the optionalmotionpeer dependency installed.
Mixing these into one entry point was the first version's actual design, and it broke in a specific, findable way: importing resolveApiBaseUrl from a component pulled the entire bundle — including postcss, magic-string, and vite-plugin-compression2 — into the client dependency graph, since esbuild had already flattened everything into a single file before Vite's bundler ever got a chance to tree-shake it. Vite's dependency scanner then warned about externalizing node:crypto, node:fs, and zlib for the browser, and the shipped JS bundle was measurably bigger for it (in one test project, 338 KB vs. 191 KB after the split — that's not a rounding error). Splitting the entry point at the source fixes it structurally instead of relying on a bundler to clean it up.
The same mistake had a second, sharper version, which is why there are three entry points rather than two: lazyDomAnimation/lazyDomMax used to sit on /client alongside resolveApiBaseUrl. They reach motion/react through wrapper modules that import it statically (see below for why that indirection is unavoidable), and motion is an optional peer dependency — so in any project that hadn't installed it, Vite resolved the wrappers to an empty optional-peer stub and vite build failed outright with MISSING_EXPORT: "domAnimation" is not exported by "__vite-optional-peer-dep:motion/react". Tree-shaking can't rescue this: resolution happens first, and merely importing the entry point is enough to put the wrappers in the graph. Every create-fronton project scaffolded next to a backon backend hit it, because the generated App.tsx imports resolveApiBaseUrl from /client. Moving the two loaders onto their own /motion entry point is what keeps /client honest about having nothing attached to it.
obfuscateTailwind(options?)
A build-only Vite plugin (apply: "build" — bun run dev stays fully readable). After Tailwind's CSS and the JS bundle are both fully generated, it collects every real class selector Tailwind actually emitted, hashes each one to a short opaque name, and rewrites both the stylesheet and known-safe class-bearing text in the built JS/HTML to match:
import { obfuscateTailwind } from "@mednours/fronton";
export default defineConfig({
plugins: [react(), tailwindcss(), obfuscateTailwind()],
});It runs in generateBundle, strictly after Tailwind and Rollup/Rolldown have both finished — so there's no ordering dependency on how @tailwindcss/vite scans source for content, and no dependency on a class-merging helper's name surviving minification (it usually doesn't: clsx(...) routinely comes out the other side as u(...), with no trace of the string "clsx" left anywhere). Class-bearing values are found by position — the value of a className/class property, however it got there — not by naming known helpers, which is what makes it keep working after minification renames everything around it.
What it rewrites:
className="..."/class="..."(a plain string or template literal, in either compiledclassName:"..."/className:\...`` object-property form or raw HTML attribute form)- string-literal arguments inside any expression used as a
className/classvalue — aclsx()/cn()call, a ternary, a logical expression — regardless of what produced it or what it's been renamed to
What it leaves alone, on purpose:
- a class list built at runtime from an array or object with no literal strings in scope
- a template literal with real
${}interpolation
Every rewrite is checked against the real set of classes Tailwind's own generated CSS defines — a token only ever gets renamed if it's provably a real class, never a guess based on pattern-matching arbitrary text. That's what makes the failure mode "under-obfuscates a dynamic string" rather than "corrupts unrelated text somewhere in the bundle."
Options:
enabled(defaulttrue) — turn the whole plugin off for a one-off debug build without editingvite.config.ts.safelist(default[]) — specific class names to never rename, for classes an external stylesheet, an end-to-end test selector, or a third-party script depends on by their real Tailwind name.salt(default"") — mixed into every class's hash. Two fronton projects with an identical class set otherwise produce identical hashes; set this if that's not what you want.
frontonCompression(options?)
Gzip + Brotli, every production build, on by default:
import { frontonCompression } from "@mednours/fronton";
export default defineConfig({
plugins: [react(), tailwindcss(), obfuscateTailwind(), frontonCompression()],
});Order matters — list it after obfuscateTailwind() (as above) so the compressed files reflect the already-obfuscated output, not the pre-rename one. Options: threshold (default 1024 bytes — skip files too small for compression to pay for itself), deleteOriginalAssets (default false — keep the uncompressed copy alongside .gz/.br so a host that doesn't serve precompressed files still works), overrides (passed straight through to the underlying vite-plugin-compression2 for anything not covered above).
backonProxy(target, options?)
Builds a Vite dev-server server.proxy entry pointed at a backon backend:
import { backonProxy } from "@mednours/fronton";
export default defineConfig({
server: { proxy: backonProxy("http://localhost:3000") },
});fetch("/api/users") during bun run dev reaches the real backend with no CORS round-trip or hardcoded port baked into application code. target is a full origin; create-fronton fills this in automatically when it detects a sibling backon backend at scaffold time. Options: prefix (default "/api"), stripPrefix (default true — a request to /api/users reaches the backend as /users, matching how backon mounts routes at their real path rather than under an /api namespace of their own).
resolveApiBaseUrl() (from @mednours/fronton/client)
import { resolveApiBaseUrl } from "@mednours/fronton/client";
fetch(`${resolveApiBaseUrl()}/api/users`);Reads import.meta.env.VITE_API_BASE_URL, returning "" (relative requests) when it's unset — correct for same-origin deployment or the dev-server proxy above. This exists because the obvious alternative, a process.env check (the pattern backon's own generated generate-client script uses, correctly, since that script runs under Node/Bun), does nothing useful in a browser bundle: process isn't defined there, so the check just silently falls through. Only VITE_-prefixed variables are ever exposed to client code — Vite drops everything else on purpose, so a backend secret sitting in the same .env file never leaks into the browser bundle.
lazyDomAnimation() / lazyDomMax() (from @mednours/fronton/motion)
Pre-built <LazyMotion features={...}> loaders for motion (née Framer Motion):
import { LazyMotion, m } from "motion/react";
import { lazyDomAnimation } from "@mednours/fronton/motion";
<LazyMotion features={lazyDomAnimation}>
<m.div animate={{ opacity: 1 }} initial={{ opacity: 0 }} />
</LazyMotion>;This exists because motion's own <LazyMotion features={domAnimation}> example is a trap if domAnimation gets there via a plain, static import { domAnimation } from "motion/react" — that still pulls the whole animation engine into the main bundle, exactly as expensive as using motion.div directly. features only saves anything when it's a function that dynamically imports the bundle. Verified against a real Vite build: the static form produced one 265.81 KB chunk (85.91 KB gzip); switching to a real dynamic loader dropped the main chunk to 231.51 KB (74.58 KB gzip) and moved the rest into its own 37.42 KB chunk (14.18 KB gzip) that only loads once something on the page actually animates.
The less obvious trap, also verified against a real build: dynamically importing "motion/react" itself doesn't work either. Every component using <LazyMotion>/<m.div> has to statically import LazyMotion/m from "motion/react" — there's no lazy way to get those — and Rollup refuses to split a module into its own chunk if anything in the build graph already imports that exact same specifier statically, even from an unrelated file (an "ineffective dynamic import" — collapses back into one chunk). lazyDomAnimation/lazyDomMax work around this by each dynamically importing a tiny dedicated internal module that statically re-exports just one feature bundle, giving Rollup a boundary that isn't already claimed elsewhere. Confirmed end-to-end, not just in theory: this package's own build (esbuild with --splitting) preserves that boundary as a real separate chunk file, and feeding its actual dist/motion.js output into a real downstream Vite build reproduced the same 231.51/37.42 KB split.
lazyDomAnimation covers drag-free interactions (hover/tap/enter-exit/animate); lazyDomMax is a strict superset that adds layout and drag animations — same as motion's own domAnimation/domMax. motion is an optional peer dependency (bun add motion) — install it before importing this entry point at all, not just before calling one of these, since the loaders reach motion/react through statically-importing wrapper modules that a bundler has to resolve either way.
compressImages(options?) and compressVideos(options?)
Opt-in — not wired into create-fronton's generated vite.config.ts by default, and not installed by default either (see "Peer dependencies" below). Both re-encode media files in the finished output directory in place, keeping the result only if it's smaller than the original — same "never make it worse" contract frontonCompression's skipIfLargerOrEqual uses:
import { compressImages, compressVideos, frontonCompression, obfuscateTailwind } from "@mednours/fronton";
export default defineConfig({
plugins: [react(), tailwindcss(), obfuscateTailwind(), frontonCompression(), compressImages(), compressVideos()],
});Both run in closeBundle — the last hook Vite/Rollup fire — and walk the real outDir on disk, not Rollup's in-memory bundle. That's a deliberate difference from obfuscateTailwind's generateBundle approach, and it isn't cosmetic: public/ is copied to outDir by Vite as a step outside Rollup's bundle machinery entirely, so a generateBundle hook never sees those files — and a hero video or photo living in public/ (the common case; nobody imports a multi-megabyte video from a component) would silently never get touched by a generateBundle-based version of this. This was caught by actually running a real Vite build against the first version of these two plugins, not by reasoning about it — the first version compressed nothing on a real project and logged no error, because iterating over an empty match set doesn't look like a bug from the inside. Walking outDir after everything — Rollup's own output and the public/ copy — is actually on disk is the only way to reach both.
compressImages re-encodes .jpg/.jpeg/.png/.webp through sharp — quality (default 75) for jpeg/webp, and lossy palette quantization (sharp's pngquant-equivalent) for png, which is where most of a PNG's real size win comes from, not a lossless recompress. This is a fixed-dimension, same-format squeeze — it doesn't generate multiple sizes, srcset, or <picture> markup with format fallbacks; reach for @responsive-image/vite-plugin or vite-imagetools if you need that. It also can't reach a file small enough to fall under Vite's own build.assetsInlineLimit (4 KiB by default) — those get base64-inlined into JS/CSS text before this plugin ever runs, so there's no standalone file left to touch; that's rarely a real photo in practice. Options: enabled, quality, extensions (default [".jpg", ".jpeg", ".png", ".webp"]), cacheDir.
compressVideos re-encodes .mp4 through ffmpeg (H.264/AAC, +faststart) via fluent-ffmpeg + ffmpeg-static — a prebuilt ffmpeg binary shipped as the npm package itself, so nothing needs to be separately installed or on PATH. Only .mp4 by default: a .webm source is almost always already VP9/AV1, and re-encoding it through libx264 would silently change codec rather than just compress it — add other extensions via extensions only once you've confirmed libx264 is the right target. Options: enabled, crf (default 28 — noticeably smaller than libx264's own default of 23), preset (default "slow" — worth spending more encode time than ffmpeg's own "medium" default, since the cost is paid once per unique input and cached), extensions (default [".mp4"]), cacheDir.
Both cache their output across builds (default node_modules/.cache/fronton-media, keyed by a hash of the file's own bytes plus the active options — same idea as obfuscateTailwind's salt, so changing quality/crf/preset correctly busts the cache instead of serving a stale result). Re-encoding — video especially — is real CPU work; without this, every build (including every CI run) would pay that cost again for a file that hasn't changed since last time. Verified against real sharp/ffmpeg output, not mocked: a solid-color PNG shrinks ~92% via palette quantization, and a deliberately high-quality (low-crf) synthetic test clip shrinks measurably through the plugin's default crf 28 — both checked directly, and separately confirmed end-to-end in a real vite build against files placed in public/.
Neither plugin fails a build if its dependencies are missing — each logs a warning and does nothing, so leaving sharp or fluent-ffmpeg/ffmpeg-static uninstalled is a safe way to keep them present in vite.config.ts but effectively off.
Peer dependencies
vite is required. sharp, fluent-ffmpeg, ffmpeg-static, and motion are optional peer dependencies — only needed if you actually use the functions that need them:
bun add sharp # for compressImages
bun add fluent-ffmpeg ffmpeg-static # for compressVideos
bun add -D @types/fluent-ffmpeg # optional, for types
bun add motion # for lazyDomAnimation / lazyDomMaxThey're kept out of dependencies on purpose: sharp and ffmpeg-static are large native binaries, motion is a real animation library most projects don't need, and most projects using obfuscateTailwind/frontonCompression/backonProxy have no media to compress or animations to run at all — there's no reason for bun install to pay for any of them unless you're actually calling the function that needs it.
Requirements
Bun >= 1.3, Vite >= 8 (peer dependency — not bundled, so your project's own Vite is what actually runs).
