npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/fronton

Two separate entry points, deliberately not one:

  • @mednours/fronton (the root import) — Node-side, meant for vite.config.ts only: 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 optional motion peer 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 compiled className:"..."/className:\...`` object-property form or raw HTML attribute form)
  • string-literal arguments inside any expression used as a className/class value — a clsx()/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 (default true) — turn the whole plugin off for a one-off debug build without editing vite.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 / lazyDomMax

They'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).