@charcuterie/server

中文文档
0.5.1发布于 2026/08/28MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2483

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

原始使用文档
文档目录15 节
  1. Why this exists
  2. Usage
  3. Deploy updates in open tabs
  4. MQTT cmd/resp
  5. Outbound HTTP cache and throttle
  6. Lifetime
  7. ⚠️ A 304 saves bandwidth. It does NOT save budget.
  8. The four politeness axes are four different things
  9. Also handled
  10. What you get
  11. The two cache buckets
  12. Options
  13. Mounting a directory that lives somewhere else
  14. Migrating a hand-rolled handler
  15. Peer dependencies

@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

OptionMeans
minIntervalMsThe minimum gap between two starts.
maxConcurrentHow many may be in flight at once.
maxPerWindow + windowMsA budget that refills.
cooldownMsEverything 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.
RevalidationETag + If-None-Match → 304 with no body, applied only to the no-cache bucket.
StreamingcreateReadStream, so a megabyte never lands in the heap.
CorrectnessRange 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 else — index.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

OptionDefault
rootDirrequiredAbsolute 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.
hasSpaFallbacktrueServe 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.

版本与发布记录

@charcuterie/server 共发布 6 个版本,首次发布于 2026/08/10;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.5.1latest2026/08/282 个月前
0.5.02026/08/282 个月前
0.4.02026/08/262 个月前
0.3.02026/08/162 个月前
0.2.02026/08/102 个月前
0.1.02026/08/102 个月前

关于 @charcuterie/server 的常见问题

@charcuterie/server 是什么?

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

如何安装 @charcuterie/server?

在项目目录运行 npm install @charcuterie/server,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@hono/node-server(>=1.13)、hono(>=4)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@charcuterie/server 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@charcuterie/server 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@charcuterie/server 可以商用吗?

@charcuterie/server 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@charcuterie/server 的下载量有多少?

根据 npm 官方下载统计,@charcuterie/server 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,483 次,日均约 83 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@charcuterie/server 还在维护吗?

最新版本 0.5.1 发布于 2026/08/28(2 个月前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/08/10。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。