hono-shaking
v0.4.0
Published
Find unused Hono RPC endpoints. Type-driven detection that auto-discovers server/client pairs across a monorepo. Svelte / Vue / tsgo friendly.
Maintainers
Readme
hono-shaking
Find unused Hono RPC endpoints. Type-driven, monorepo-aware, Svelte / Vue / tsgo friendly.
hono-shaking is a static analyzer for projects that use Hono's
RPC client (hc<AppType>()). It reads
the server's exported AppType via the TypeScript Compiler API, walks the
client source for hc<>() call sites, and reports the routes the server
defines that nobody actually calls.
$ npx hono-shaking
# Discovered servers
apps/api/src/index.ts :: AppType (124 routes)
# Discovered bindings
apps/web/src/lib/client.ts :: backendClient → apps/api/src/index.ts :: AppType
== apps/api :: AppType ==
consumers: apps/web::backendClient(118)
defined routes : 124
used routes : 118
unused routes : 6
orphan calls : 0
Unused routes (6)
POST /api/v1/integrations/zendesk/webhook
GET /api/v1/integrations/salesforce/oauth/callback
GET /api/v1/dashboards
...Why
Hono RPC ties every client call to a server route through the type system.
Once you have that wire — hc<AppType> — you also have everything you need
to ask the opposite question: which server routes does nobody on the
client ever call?
Existing dead-code tools (knip, ts-prune, …) reason at the export level.
They can't say "this route handler is unused" because the route is reachable
via its registration in app.get(...). hono-shaking operates at the
route-schema level instead, which is the unit of dead code that actually
matters for an HTTP API.
Use cases:
- PR gate: fail CI if a PR adds a new endpoint with no client call.
- Refactor planning: find handlers safe to delete after a frontend rewrite.
- Schema drift detection: orphan call sites surface typos and removed
routes that the type checker missed (e.g. inside
.svelte/.vue).
When NOT to use this
- Your API isn't called via
hc<AppType>(...)(REST clients, fetch wrappers, generated SDKs). The analyzer keys on the hc proxy; outside that shape every route looks unused. - Your endpoints are called only by external systems (webhook receivers,
OAuth callbacks, public APIs). They'll show up as "unused" — list them
under
ignore.routesto suppress. - You're looking for unused functions / files in general. Use
knipfor that.
Install
pnpm add -D hono-shaking # or npm / yarnFramework support is via optional peer dependencies; install only the ones your project uses.
# For .svelte files
pnpm add -D svelte2tsx svelte
# For .vue files
pnpm add -D @vue/compiler-sfcWithout these, hono-shaking still works on .ts / .tsx files; framework
files are just skipped.
tsgo
hono-shaking uses the standard typescript
package's Compiler API to read your AppType. It never invokes tsc as a
binary. Projects that have switched their build / type-check to
tsgo are unaffected — the
bundled typescript runs alongside, reading the same tsconfig.json.
Usage
Auto-detect (recommended)
Run with no arguments from the repository root:
npx hono-shakingThis is equivalent to --root .. Pass --root <dir> to point at a
different location.
It walks the tree, finds every export type X = typeof Y that looks like a
Hono app, finds every hc<T>(...) call, and resolves each T through the
TypeScript checker to pair them up. Monorepos with multiple servers and
multiple frontends are supported out of the box.
Auto-detect handles two binding patterns:
| Pattern | Example |
| ------- | ------------------------------------------------------------ |
| Direct | const client = hc<AppType>(url) |
| Factory | const make = () => hc<AppType>(url); const client = make() |
The factory pattern is common when you wrap hc() in a function to inject
headers / fetch options — auto-detect follows the function definition and
finds the consumers.
Manual
If you only want to analyze one server / client pair, pass them explicitly:
npx hono-shaking \
--server-tsconfig apps/api/tsconfig.json \
--app-type-file apps/api/src/index.ts \
--client-tsconfig apps/web/tsconfig.json \
--client-dir apps/web/srcCI
# Fail the build if a PR introduces an unused route (or call sites with no matching server route).
npx hono-shaking --fail-on-unused --fail-on-orphansThe exit code reflects findings after the config-driven ignore list is
applied. Adding a route to ignore.routes is how a team explicitly accepts
a non-hc endpoint without breaking the build.
JSON output
npx hono-shaking --json > report.jsonDiagnostic messages (# adapters loaded: ..., # config: ...) go to stderr
so JSON on stdout stays clean for pipelines.
Configuration
Drop a hono-shaking.config.ts (or .mts / .mjs / .js / .cjs) in the
working directory — or, in a monorepo, in the repo root. The loader walks
up the filesystem from the directory you run hono-shaking in, so a single
config at the repo root applies whether you run from the root or from any
sub-package. It's loaded by jiti — the
same TS loader Nuxt and Vitest use — so transitive .ts imports work
without a build step.
// hono-shaking.config.ts
import { defineConfig } from "hono-shaking";
export default defineConfig({
ignore: {
routes: [
// SSE / streaming endpoints — called via raw fetch, not hc.
{ method: null, path: "/api/sse/**", reason: "SSE — raw fetch" },
// OAuth callbacks — invoked by the IdP, not the frontend.
{ method: "GET", path: "/api/oauth/**", reason: "IdP callback" },
// Webhooks called by external systems.
{ method: "POST", path: "/api/webhooks/zendesk" },
],
orphans: [
// A call to a different backend that legitimately doesn't appear
// in the AppType we're analyzing.
{ method: "GET", path: "/token", file: "**/tiptap/Editor.svelte" },
],
},
});Monorepo
In a monorepo, place the config at the repo root. Path-shaped fields
(serverAppTypeFile on route ignores, file on orphan ignores) without a
leading / or * are interpreted relative to the config file's
directory — so you can scope rules to a specific package without
remembering **/ prefixes:
// <repo-root>/hono-shaking.config.ts
import { defineConfig } from "hono-shaking";
export default defineConfig({
ignore: {
routes: [
// Only ignore on the `apps/api` server, not on any other server in
// the repo that happens to expose the same path.
{
method: "POST",
path: "/api/v1/webhooks/**",
serverAppTypeFile: "apps/api/src/index.ts",
reason: "External webhooks",
},
],
orphans: [
// Scope an orphan-ignore to one package.
{ method: "GET", path: "/token", file: "apps/web/src/lib/tiptap/Editor.svelte" },
],
},
});Run hono-shaking from the repo root, from apps/api, or from anywhere
in between — the same config is used.
Schema
interface HonoShakingUserConfig {
ignore: {
routes: IgnoreRoutePattern[] | null;
orphans: IgnoreOrphanPattern[] | null;
} | null;
}
interface IgnoreRoutePattern {
/** `null` matches any method. */
method: HttpMethod | HttpMethod[] | null;
/** Glob (`*` = one segment, `**` = any depth) or array. */
path: string | string[];
/** Restrict to a specific server's AppType file (glob, optional). */
serverAppTypeFile: string | null;
/** Documentation only. */
reason: string | null;
}
interface IgnoreOrphanPattern {
method: HttpMethod | HttpMethod[] | null;
path: string | string[] | null;
/** Glob against the call site's file path. */
file: string | null;
reason: string | null;
}CLI overrides
| Flag | Purpose |
| ----------------------- | ----------------------------------------------------------------- |
| --config <path> | Explicit config file path. |
| --no-config | Skip config auto-discovery. |
| --fail-on-dead-config | Exit 1 if any ignore rule never matched (good for CI). |
| --no-warn-dead-config | Silence the default-on warning that lists unmatched ignore rules. |
Dead-config detection
Routes get renamed and removed. Without a check, the corresponding entries
in ignore.routes / ignore.orphans quietly outlive the code they refer
to — adding visual noise to the config and, worse, masking a future
unused-route that happens to match the stale rule.
By default hono-shaking warns (on stderr) when any ignore rule never
matched anything during the run:
warning: 2 ignore rules in hono-shaking.config.ts never matched:
ignore.routes[1] method="DELETE" path="/api/v1/this-was-removed" (Removed last quarter)
ignore.orphans[0] method="GET" path="/never-called"
Rules may be unmatched because the route was renamed/removed, or
because an earlier rule shadowed them.Pass --fail-on-dead-config in CI to turn this into a hard error so the
config can't rot. Pass --no-warn-dead-config to suppress the warning.
How it works
Extract routes from the server. We resolve the
AppTypesymbol via the Compiler API and walk Hono's schema type parameter{ [path]: { $get: Endpoint, $post: Endpoint, ... } }. Chained.route(...)calls accumulate the schema as a union; we flatten the union and merge all members' keys.Detect call sites in the client. For every
obj.$get(...)(or$post, etc.) we ask the type checker whether the receiver has Hono's$urlproperty — that's a unique marker on the RPC proxy's leaf nodes, and it's the central precision gate that keeps us from matchingobj.$get(...)on unrelated objects.Truncate the chain at the rightmost known hc client name. This is what handles
params.backendClient.api.v1.users.$get()— the client variable lives in the middle of the chain, not at the root.Diff defined vs called by
(method, path)to produceunused/used/orphan.Discover (for
--rootmode) walks the repo for server and client candidates, runs the type checker to validate each candidate and to resolvehc<T>to its declaring file, and groups bindings by server so "unused" is calculated across all consumers of a server, not per consumer.
For .svelte and .vue, each file is run through a FrameworkAdapter
(svelte2tsx or @vue/compiler-sfc) that emits a virtual TS source plus a
sourcemap-style position remapping. Adapter scans don't have a type
checker, so they rely on the whitelist of hc client names discovered by the
TS pass.
Library API
The CLI is a thin shell over a library API. Use it directly when you need something the CLI doesn't expose (custom output, custom ignores derived from runtime data, etc.).
import {
discoverProject,
extractRoutes,
findCallsites,
diffRoutes,
buildIgnoreFilter,
loadConfig,
} from "hono-shaking";
const { servers, bindings } = discoverProject(".");
for (const binding of bindings) {
const defined = extractRoutes({
tsconfigPath: binding.server.tsconfigPath,
appTypeFile: binding.server.appTypeFile,
exportName: binding.server.exportName,
});
const called = await findCallsites({
tsconfigPath: binding.clientTsconfigPath,
includeDir: binding.clientPackageDir,
exclude: null,
knownClientNames: null,
restrictToClientNames: [binding.variableName],
adapters: null, // auto-load svelte / vue if installed
});
const diff = diffRoutes(defined, called);
// diff.unused, diff.used, diff.orphanCalls
}Known limitations
- Computed access:
client[someVar].$get()with a non-literal key is skipped. Literal keys (client['users'].$get()) work fine. - Cross-repo clients: a
.tsfile outside the analyzed program isn't visible. Run hono-shaking in the repo where both server and client live, or run it twice (once per repo) and union the results.
Imports we do handle
hc resolution goes through the TypeScript symbol resolver, so any shape
the compiler can follow works — including aliases, namespace imports, and
re-exports through your own barrels:
import { hc } from "hono/client";
import { hc as createClient } from "hono/client";
import * as hono from "hono/client";
// ^ then called as hono.hc<T>(...)
// Re-export through your own barrel:
// @org/backend/client.ts: export { hc } from 'hono/client';
import { hc } from "@org/backend/client";
import { hc as createClient } from "@org/backend/client";