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

@charcuterie/server

v0.5.1

Published

The fleet's Node server kit — Hono static-asset handler, outbound HTTP cache policy, and MQTT cmd/resp.

Readme

@charcuterie/server

The fleet's Node server kit: precompressed bytes, honest cache headers, and one 304 where it helps, plus MQTT cmd/* / resp/* for talking to Home Assistant. The Vite plugin that produces the compressed bytes lives beside the static handler because the two halves are one contract and shipping them apart is how they drift. MQTT is a separate subpath so a static-only app never resolves mqtt.

Why this exists

Six apps in the fleet serve a Vite SPA from a Hono server. All six hand-rolled it, and an audit in August 2026 found that none of them compressed anything. Mux-Magic was the worst case and the one that prompted this package: it served a 1.02 MB bundle uncompressed (290 kB gzipped) under Cache-Control: no-cache, no-store, must-revalidate — on a content-hashed filename. Every visit, every reload, every client-side navigation re-downloaded 1.2 MB that could have been 318 kB and could have been cached forever.

The failure is not that anyone was careless. It is that "serve a directory" looks like twenty lines, and the twenty lines leave out compression, cache policy, streaming, Range, MIME types, traversal defence, and the difference between a missing route and a missing asset.

Usage

Two halves. Wire both.

// vite.config.ts — the build half
import { precompressAssets } from "@charcuterie/server/vite"
import { createViteConfig } from "@charcuterie/vite-config"

export default createViteConfig({
  plugins: [react(), precompressAssets()],
})
// server — the serve half
import { createStaticHandler } from "@charcuterie/server"

app.route("/api", apiApp)
app.use("*", createStaticHandler({ rootDir: webDistDir }))

Adopting them in either order is safe: with no .br/.gz siblings on disk the handler serves the originals, and the siblings are inert until something looks for them.

Deploy updates in open tabs

createStaticHandler also serves a no-cache build marker at /__charcuterie/deployment and an SSE marker stream at /__charcuterie/deployment/events. The marker is a SHA-256 hash of the deployed index.html. A Vite build changes the shell when it names a new asset set.

An EventSource reconnects after the container replacement. Its first event from the new container contains the new marker. Pair the server with useDeploymentUpdate from @charcuterie/ui; it reports isUpdateAvailable and gives the app a reload action. The hook does not reload a tab by itself because the tab can have unsaved work.

An app that already has an SSE connection can avoid a second stream:

const { checkForUpdate, isUpdateAvailable, reload } = useDeploymentUpdate({
  isEventSourceEnabled: false,
})

// In the existing SSE source's successful reconnect callback:
void checkForUpdate()

Render an accessible button when isUpdateAvailable is true, and call reload from that button. The static shell already sends Cache-Control: no-cache, so the reload fetches the new shell and its new immutable asset URLs. Set both deploymentPath and deploymentEventsPath to false only for an asset-only origin with no browser client.

MQTT cmd/resp

Node-only. Import @charcuterie/server/mqtt, not the main barrel, and add mqtt as a dependency of the app. Command and response topics are never retained — a broker replay must not re-run a nightly. Overlapping commands for the same action are rejected with { ok: false, reason: "already-running" }.

import { createMqttService } from "@charcuterie/server/mqtt"

const mqtt = await createMqttService({
  base: "board-game-picker",
  host: process.env.MQTT_HOST,
  password: process.env.MQTT_PASS,
  username: process.env.MQTT_USER,
})

mqtt.handleCommand("sync", async (payload) => {
  // run the work
  return { ok: true, payload }
})
// board-game-picker/cmd/sync  →  board-game-picker/resp/sync

TLS defaults on when the port is 8883 (mqtt.octen.dev). Pass isTls to override. truenas-mqtt's trigger/status tree is a legacy special case — new apps use cmd/resp. This does not belong in @charcuterie/streams (browser, push-only).

Outbound HTTP cache and throttle

The opposite direction from everything above: this is about responses the app receives from somebody else's server. Import @charcuterie/server/http. Zero dependencies, no fetch of its own.

The library never owns storage. Five apps in the fleet cache third-party HTTP and all five picked a different substrate — two SQLite tables, two directories of JSON files, one memory Map written through to a file — and every one is right where it sits. What repeats is the policy, so that is what moved here: how long an answer is still true, and how often we are allowed to ask.

import {
  createHttpCache,
  createThrottle,
  ONE_DAY_MS,
} from "@charcuterie/server/http"

const cache = createHttpCache<string>({
  fetchFromOrigin: async ({ etag, key }) => {
    const response = await fetch(key, {
      headers: etag == null ? {} : { "if-none-match": etag },
    })

    if (response.status === 304) return { outcome: "unchanged" }
    if (response.status === 404) return { outcome: "missing" }
    if (!response.ok) return { outcome: "unavailable" }

    return {
      etag: response.headers.get("etag"),
      outcome: "payload",
      payload: await response.text(),
    }
  },
  lifetime: ONE_DAY_MS,
  store: {
    // Whatever the app already has. Synchronous is fine.
    read: ({ key }) => selectRow(key),
    write: ({ key, record }) => upsertRow(key, record),
  },
  throttle: createThrottle({ minIntervalMs: 1_000 }),
})

const { payload, source } = await cache.get({ key: url })

Lifetime

"immutable" | "none" | number. "immutable" is a claim about the URL, not a hint — a file at a 40-character commit hash cannot become a different file. "none" keeps a read out of the store entirely. A number is milliseconds, and it is set at the call site, because only the call site knows how fast its answer moves.

⚠️ A 304 saves bandwidth. It does NOT save budget.

Measured against the live GitHub API on 2026-08-24, unauthenticated: every conditional request came back 304 and x-ratelimit-remaining still fell by one. GitHub documents a 304 as free against the primary rate limit, and the unauthenticated per-address limit is not that limit. An ETag buys the body back, never the budget. Only a lifetime that has not run out saves budget, because the only free request is the one never sent.

The four politeness axes are four different things

| Option | Means | | --- | --- | | minIntervalMs | The minimum gap between two starts. | | maxConcurrent | How many may be in flight at once. | | maxPerWindow + windowMs | A budget that refills. | | cooldownMs | Everything stops after a failure. |

A gap is not a budget: one request per second permits 3600 an hour, and a 60-an-hour budget permits three in the first second. run queues for a slot; tryRun returns null rather than queueing, for a caller on a poll loop that would rather drop the work and pick it up next tick.

Also handled

  • Negative caching. missLifetime remembers "the origin had no such thing", which one app already depends on. Defaults to "none".
  • Stale-while-revalidate. isStaleWhileRevalidate returns the stale body at once and refreshes behind it. Off by default.
  • Single flight. Concurrent reads of one key collapse into one request.
  • Failures are values. An "unavailable" origin is never written down, serves the stale body, and starts the cooldown. Store errors degrade to a miss and go to onError; a cache is never the reason a page fails to render.

Naming is settled — fetchedAt / expiresAt / etag, no Ms suffix on a *At field, Ms kept on every duration (decision).

What you get

| | | | --- | --- | | Compression | .br / .zst / .gz siblings, negotiated against Accept-Encoding, with Vary set. Written at build time at Brotli quality 11 — the bytes never change, so deriving them per request burns CPU while the user waits. | | Caching | /assets/*public, max-age=31536000, immutable. Everything else → no-cache. | | Revalidation | ETag + If-None-Match → 304 with no body, applied only to the no-cache bucket. | | Streaming | createReadStream, so a megabyte never lands in the heap. | | Correctness | Range requests, MIME lookup, ../ traversal defence, and a missing .js that 404s instead of returning HTML. |

The two cache buckets

There are only two kinds of file in a Vite dist/, and they want opposite headers.

Content-hashed files (/assets/index-D7e1J0tu.js) can never change behind their name, so they are immutable — not merely max-age, which still permits a revalidation round-trip on reload.

Everything elseindex.html, anything in public/ — keeps its name across deploys and must be revalidated every time. no-cache does not mean "do not cache"; it means "cache, then revalidate before reuse". Paired with an ETag the usual answer is a 304 with no body.

no-store is the header that means "do not cache", and putting it on a hashed asset is the bug this package exists to delete.

Bucketing is by path prefix, not by a hash-shaped regex. Vite's assetsDir is a build guarantee; "does this filename look hashed" is a guess that is wrong in both directions (vendor-legacy.js isn't hashed, logo-v2.png isn't either). Override with immutablePathPrefixes if your assetsDir isn't assets, or to add your own content-addressed directory:

createStaticHandler({
  immutablePathPrefixes: ["/assets/", "/images/"],
  rootDir: webDistDir,
})

The list replaces the default rather than extending it, so an app that renames assetsDir and forgets to say so gets the safe answer — revalidated — instead of a year of stale caching.

Options

| Option | Default | | | --- | --- | --- | | rootDir | required | Absolute path to the build output. Relative paths resolve against process.cwd() — that is serveStatic's behaviour, not ours. | | immutablePathPrefixes | ["/assets/"] | Request-path prefixes that may be cached forever. | | index | "index.html" | The SPA shell, relative to rootDir. | | hasSpaFallback | true | Serve index for extensionless paths that match no file. Turn off for a pure asset origin. | | rewriteRequestPath | — | Map the request path onto rootDir before the lookup, for a mount whose URL prefix is not a real directory. |

Mounting a directory that lives somewhere else

board-games serves /images/* out of $BOARD_GAMES_IMAGES, which is nowhere near the web root:

app.use("/images/*", createStaticHandler({
  immutablePathPrefixes: ["/images/"],
  rewriteRequestPath: (path) => path.replace(/^\/images/, ""),
  rootDir: imagesDirectory(),
}))

The cache bucket is still decided on the request path, not the rewritten one — so immutablePathPrefixes keeps naming URLs as a caller sees them. Rewriting is about where bytes live on disk; caching is about what the browser was promised. The SPA fallback is unaffected: index resolves against rootDir directly, so a rewrite cannot misdirect the shell.

precompressAssets() takes algorithms (default ["br", "gz"]) and thresholdBytes (default 1024). zst is supported but near-pointless as a third: the handler prefers Brotli, every browser that speaks zstd also speaks Brotli, and gzip is already the floor for the ones that speak neither.

Migrating a hand-rolled handler

Every version in the fleet is one of three shapes.

The readFileSync loop (points-market, mail-sifter, gallery-downloader, Mux-Magic) — a content-type map, a Cache-Control ternary, an SPA fallback, and a traversal guard. Delete all of it, including the CONTENT_TYPES constant and the isWithinRoot helper.

-app.use("*", async (context) => {
-  const filePath = /* …twenty lines… */
-  context.header("Content-Type", CONTENT_TYPES[ext] ?? "application/octet-stream")
-  context.header("Cache-Control", "no-cache, no-store, must-revalidate")
-  return context.body(readFileSync(filePath))
-})
+app.use("*", createStaticHandler({ rootDir: webDistDir }))

The bare serveStatic (board-games) — already streaming, but with no compression and no cache policy. Swap the call.

serveStatic + onFound (board-games' /images/*) — ⚠️ this one is silently broken today. onFound runs after serveStatic has built the Response, so the headers it sets are dropped on the floor; board-games' immutable on box art has never reached a browser. Verified against @hono/node-server 2.1.0:

serveStatic({ root, onFound: (_p, c) => c.header("Cache-Control", "immutable") })
// → response has no cache-control header at all

Set headers in a middleware before serveStatic, which is what this package does.

Peer dependencies

hono and @hono/node-server are required peers — the app owns the versions. vite is an optional peer needed only by the /vite entry point, so a server never resolves Vite and a build never resolves Hono. mqtt is an optional peer needed only by the /mqtt entry point, so a static-only app never resolves a broker client. The /http entry point has no dependencies at all — not even Hono.