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

hono-openapi-scan

v1.2.1

Published

Zero-config OpenAPI 3.1 spec generator for Hono. Scans your codebase — no wrappers, no runtime cost.

Downloads

605

Readme

hono-openapi-scan

npm license

Scan your Hono codebase. Get an OpenAPI 3.1 spec. No wrapper functions. No runtime middleware. No migration.

bun add -D hono-openapi-scan
bunx hono-openapi-scan init   # create config
bunx hono-openapi-scan        # generate openapi.json

Contents

Walkthrough — Your First Spec

1. Install

bun add -D hono-openapi-scan

2. Initialize config

bunx hono-openapi-scan init

This creates hono-openapi-scan.config.ts at your project root:

import { defineConfig } from 'hono-openapi-scan'

export default defineConfig({
  info: {
    title: 'My API',            // defaults to package.json#name
    // version: '1.0.0',        // defaults to package.json#version
    // description: '...',      // defaults to first line of README.md
  },

  // servers: [{ url: 'http://localhost:3000', description: 'Local' }],
  // security: [{ bearerAuth: [] }],
  // securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
  // tags: [{ name: 'Users', description: 'User management' }],

  entry: 'src/index.ts',        // where your Hono app lives
  output: 'openapi.json',       // where the spec is written
})

See test/fixture/hono-openapi-scan.config.ts for a full production example with all supported fields.

3. Scan

bunx hono-openapi-scan
Scanning entry: src/index.ts
Resolved 182 source files
Found 3 Hono app(s)
  app: 17 route(s)
Total unique routes: 15
Wrote OpenAPI spec to: openapi.json

4. Serve

Serve the spec with any OpenAPI-compatible tool:

# Scalar (recommended)
bunx @scalar/cli openapi.json

# Swagger UI
bunx swagger-ui-watcher openapi.json

# Or just commit it — CI can validate it

5. (Optional) Add to CI

// package.json
{
  "scripts": {
    "openapi": "hono-openapi-scan",
    "openapi:check": "hono-openapi-scan && git diff --exit-code openapi.json"
  }
}

How It Works

The scanner reads your source code with ts-morph (a TypeScript compiler wrapper). It never runs your code — pure static analysis.

Your Hono project
  │
  ├─ src/index.ts          ← Entry: new Hono(), .use(), .route()
  ├─ src/routes/users.ts   ← .get() / .post() + zValidator
  ├─ src/schemas/index.ts  ← Zod schemas with .describe()
  └─ src/db/schema.ts      ← Drizzle tables with // comments
  │
  ▼  ts-morph reads the AST
  │
  ├─ Finds every .get/.post/.put/.patch/.delete
  ├─ Resolves .route() sub-routers across files
  ├─ Extracts zValidator → request body + query params
  ├─ Detects c.json() → response status codes
  ├─ Walks Zod AST → full JSON Schema with $ref
  ├─ Reads Drizzle columns → JSON Schema with readOnly
  ├─ Detects Better Auth middleware → security + 401
  └─ Parses JSDoc → tags, summaries, @public, @returns
  │
  ▼
openapi.json  ← valid OpenAPI 3.1

For a deep dive, read How It Works.

Why hono-openapi-scan?

| | hono-openapi-scan | @hono/zod-openapi | hono-openapi | | -------------------- | ----------------- | ----------------- | ------------ | | Approach | Static analysis — reads your source | Runtime — wraps routes | Runtime — wraps routes | | Zero wrappers | ✅ No createRoute, no describeRoute | ❌ Must wrap every route | ❌ Must wrap every route | | Existing code | ✅ Works on any Hono project as-is | ❌ Requires migration | ❌ Requires migration | | Zod → JSON Schema| ✅ Walks Zod AST automatically | ✅ From createRoute config | ✅ From wrapper config | | Drizzle tables | ✅ Detects pgTable → JSON Schema | ❌ | ❌ | | Better Auth | ✅ Detects auth middleware → security | ❌ | ❌ | | JSDoc metadata | ✅ @tags, @public, @returns, etc. | ❌ Must use route.description | ❌ | | $ref cross-refs | ✅ Automatic between schemas | ✅ Manual via createRoute | ✅ Manual | | No runtime cost | ✅ Build/CI only — no middleware added | ❌ Adds middleware to every request | ❌ Adds middleware | | Works with RPC | ✅ Side-by-side, no conflicts | ❌ hc<AppType> breaks with wrappers | ❌ Similar conflicts | | Error docs | ✅ Auto 400/401/404/429/500 per route | ❌ Manual | ❌ Manual | | Beginner friendly| ✅ One command, no code changes | ❌ Must learn createRoute API | ❌ Must learn wrapper API |

What Gets Detected

| Pattern | Detected As | Example | |---------|------------|---------| | app.get('/users', handler) | GET /users operation | Route with method + path | | app.route('/prefix', subApp) | Prefixed sub-router | Cross-file resolution | | zValidator('json', schema) | requestBody with \$ref | JSON request body | | zValidator('query', schema) | Query parameters (expanded) | Per-field query params | | zValidator('param', schema) | Path parameters with schemas | {id} with uuid format | | zValidator('form', schema) | multipart/form-data | File uploads | | c.json(data, 200) | Response status codes | 200, 201, etc. | | export const X = z.object({...}) | components.schemas.X | Zod → JSON Schema | | pgTable('users', {...}) | components.schemas.Users | Drizzle → JSON Schema | | app.use('*', authMiddleware) | Global auth scope | security: bearerAuth | | JSDoc @public | No security on route | Override global auth | | JSDoc @tags Users | Tag grouping in docs | OpenAPI tags | | JSDoc @returns {Schema} | Response \$ref | Typed responses | | JSDoc @error 404 | Custom error response | Per-route error docs |

See Detection Patterns for full reference — Zod, Drizzle, Better Auth, JSDoc conventions, sub-router patterns, and what's out of scope.

Writing Good Code

The scanner extracts everything it can automatically. But the quality of your spec depends on the quality of your code. Here's how to get the best output.

Zod Schemas

Every field should have .describe(). The scanner uses it for description in the JSON Schema.

// ✅ Good — scanner picks up descriptions
export const CreateUserInput = z.object({
  name: z.string().min(1).max(100).describe('Display name'),
  email: z.string().email().describe('Email address'),
  role: z.enum(['admin', 'user']).default('user').describe('User role'),
})

// ❌ Bad — missing descriptions
export const CreateUserInput = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user']).default('user'),
})

Result:

{
  "name": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Display name" },
  "email": { "type": "string", "format": "email", "description": "Email address" },
  "role": { "type": "string", "enum": ["admin", "user"], "default": "user", "description": "User role" }
}

Schema cross-references are automatically resolved with $ref:

export const AuthResponse = z.object({
  token: z.string().describe('Session token'),
  user: UserSchema.describe('Authenticated user'),  // ← references UserSchema
})
// → "user": { "$ref": "#/components/schemas/UserSchema" }

Drizzle Tables

Add // comments above columns. The scanner uses them for description.

export const users = pgTable('users', {
  // Unique identifier for the user
  id: uuid('id').defaultRandom().primaryKey(),
  // Display name shown in the UI
  name: text('name').notNull(),
  // Email address used for login and notifications
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow().notNull(), // no comment → uses "Created at"
})

Result:

{
  "id": { "type": "string", "format": "uuid", "readOnly": true, "description": "Unique identifier for the user", "example": "550e8400-e29b-41d4-a716-446655440000" },
  "name": { "type": "string", "description": "Display name shown in the UI", "example": "string" },
  "email": { "type": "string", "description": "Email address used for login and notifications", "example": "string" },
  "createdAt": { "type": "string", "format": "date-time", "readOnly": true, "description": "Created at", "example": "2026-01-15T10:30:00Z" }
}

Columns without comments get auto-generated descriptions from the camelCase name (createdAt"Created at"). Generated columns (.defaultRandom(), .defaultNow(), serial()) automatically get readOnly: true.

JSDoc on Routes

Add JSDoc above your route handlers for metadata. All annotations are optional — the scanner auto-generates sensible defaults.

/**
 * List all users with pagination.
 * @description Returns a cursor-paginated list with optional role filtering
 *   and full-text search. Requires admin privileges in production.
 * @tags Users
 * @summary List users
 * @error 404
 */
app.get('/users',
  zValidator('query', UserQuerySchema),
  async (c) => { /* ... */ }
)

/**
 * Health check — no authentication required.
 * @tags Health
 * @public
 */
app.get('/health', (c) => c.json({ status: 'ok' }))

| Annotation | Purpose | |-----------|---------| | @tags {Name} | Group endpoints in docs | | @summary {text} | Short title shown in UI | | @description {text} | Full description (multi-line supported) | | @public | Skip authentication for this route | | @deprecated | Mark as sunset | | @hide | Exclude from spec entirely | | @returns {SchemaName} | Response type — generates $ref | | @error {status} | Document custom error status code | | @error none | Disable auto-generated errors for this route | | @operationId {name} | Custom operationId |

Error Responses

Every route automatically gets context-aware error responses:

| Status | When | Description | |--------|------|-------------| | 400 | Route has zValidator for body or params | Validation Error | | 401 | Route is authenticated | Unauthorized | | 404 | Path contains {param} | Not Found | | 429 | Always | Too Many Requests | | 500 | Always | Internal Server Error |

The default error schema follows RFC 9457 format:

{
  "code": "RESOURCE_NOT_FOUND",
  "message": "No resource exists with the given ID",
  "status": 404,
  "details": [{ "field": "body.email", "message": "Invalid email format", "code": "INVALID_FORMAT" }]
}

To customize, pass your own Zod schema via errorSchema:

// hono-openapi-scan.config.ts
export default defineConfig({
  errorSchema: ErrorSchema,  // import and pass your Zod object directly
})

To disable auto-errors for a specific route:

/** @error none */
app.get('/internal', (c) => c.json({}))

Better Auth

The scanner detects Better Auth middleware automatically:

// lib/auth-middleware.ts
export const authMiddleware: MiddlewareHandler = async (c, next) => {
  const session = await auth.api.getSession({ headers: c.req.raw.headers })
  // ... sets c.set('user', session.user)
}
// src/index.ts
app.use('*', authMiddleware)  // ← scanner detects this

Result: Every route under this scope gets security: [{ bearerAuth: [] }] and a 401 response. Use @public to override:

/** @public */
app.get('/health', (c) => c.json({ status: 'ok' }))

Better Auth handler routes should be hidden from the spec:

/** @hide */
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))

Schema Resolution

Zod → JSON Schema

The scanner walks Zod AST to produce full JSON Schema:

// Source
export const CreateUserInput = z.object({
  name: z.string().min(1).max(100).describe('Display name'),
  email: z.string().email(),
  role: z.enum(['admin', 'user']).default('user'),
})
// Generated
{
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Display name" },
    "email": { "type": "string", "format": "email" },
    "role": { "type": "string", "enum": ["admin", "user"], "default": "user" }
  },
  "required": ["name", "email"]
}

Drizzle → JSON Schema

Drizzle table definitions are mapped automatically:

| Drizzle | JSON Schema | |---------|------------| | uuid() | type: string, format: uuid | | text() | type: string | | varchar(n) | type: string, maxLength: n | | integer() / serial() | type: integer | | real() / doublePrecision() | type: number | | boolean() | type: boolean | | timestamp() / date() | type: string, format: date-time | | pgEnum('name', [...]) | type: string, enum: [...] | | .notNull() | Added to required | | .defaultRandom() / .defaultNow() | readOnly: true |

Output

See test/fixture/openapi.json for the complete spec generated from the test fixture — a production-grade Hono + Zod + Drizzle + Better Auth project.

Config Reference

All fields are optional. Run hono-openapi-scan init to generate a config file.

Full production example: test/fixture/hono-openapi-scan.config.ts

| Field | Type | Default | Description | |-------|------|---------|-------------| | info.title | string | package.json#name or "API" | API title | | info.version | string | package.json#version or "0.0.0" | API version | | info.description | string | First paragraph of README.md | API description | | servers | Array<{url, description?}> | [{url:'http://localhost:3000'}] | Server URLs | | security | Array<Record<string,string[]>> | [] | Global security requirement | | securitySchemes | Record<string, SecurityScheme> | none | Auth scheme definitions | | tags | Array<{name, description?}> | auto from paths | Tag groups for endpoints | | errorSchema | ZodObject | built-in RFC 9457 | Pass your Zod schema object directly | | defaultErrorResponses | boolean \| number[] | true | Auto error responses | | excludeAuth | string[] | [] | Paths that skip auth (glob patterns) | | entry | string | "src/index.ts" | Entry file to scan | | output | string | "openapi.json" | Output file path |

Library API

Use the scanner programmatically:

import { scan, defineConfig } from 'hono-openapi-scan'

// Scan and write to file
const json = await scan({
  entry: 'src/index.ts',
  output: 'openapi.json',
  info: { title: 'My API' },
})

// Or load config from file (handles defaults + package.json + README.md)
import { loadConfig } from 'hono-openapi-scan'
const config = await loadConfig('hono-openapi-scan.config.ts')
const spec = await scan(config)

Limitations

Static analysis can't see what isn't there. These patterns produce fallback schemas:

| Pattern | Result | Workaround | |---------|--------|------------| | satisfies operator (const x = {...} satisfies Type) | ts-morph can't resolve — inline schema may be incomplete | Use type annotation const x: Type = {...} or as Type | | c.get('user') / c.get('session') | Hono context generics unresolvable → empty object | Add @returns {UserSchema} JSDoc | | Dynamic routes (generated in loops) | Invisible to AST — not detected | None (static analysis constraint) | | z.preprocess() / z.transform() / z.custom() | Can't convert to JSON Schema | Use standard Zod methods; schema becomes {type: "object"} |

For patterns the scanner CAN detect, see Detection Patterns.

Development

bun install
bun run build
bun test              # 54 tests
bun run typecheck     # tsc --noEmit
bun run publish:dry   # preview npm package

Commits follow Conventional Commits. Enforced via commitlint + husky.

Contributing

git checkout -b feat/my-feature
# ... write code (pre-commit runs bun test)
git commit -m "feat: add my feature"
git push → open PR → CI runs (typecheck + test + build + commitlint)

License

MIT © Raman Karki