{"id":45712783,"url":"https://github.com/paveg/hono-problem-details","last_synced_at":"2026-04-18T02:16:24.396Z","repository":{"id":340399765,"uuid":"1165888983","full_name":"paveg/hono-problem-details","owner":"paveg","description":"RFC 9457 Problem Details middleware for Hono","archived":false,"fork":false,"pushed_at":"2026-04-11T05:57:50.000Z","size":245,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-11T06:17:16.473Z","etag":null,"topics":["cloudflare-workers","error-handling","hono","middleware","problem-details","rfc-9457","typescript"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/paveg.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["paveg"]}},"created_at":"2026-02-24T16:49:09.000Z","updated_at":"2026-04-11T05:56:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/paveg/hono-problem-details","commit_stats":null,"previous_names":["paveg/hono-problem-details"],"tags_count":13,"template":false,"template_full_name":null,"purl":"pkg:github/paveg/hono-problem-details","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-problem-details","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-problem-details/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-problem-details/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-problem-details/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/paveg","download_url":"https://codeload.github.com/paveg/hono-problem-details/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-problem-details/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31953529,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-18T00:39:45.007Z","status":"online","status_checked_at":"2026-04-18T02:00:07.018Z","response_time":103,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cloudflare-workers","error-handling","hono","middleware","problem-details","rfc-9457","typescript"],"created_at":"2026-02-25T03:07:11.606Z","updated_at":"2026-04-18T02:16:24.389Z","avatar_url":"https://github.com/paveg.png","language":"TypeScript","funding_links":["https://github.com/sponsors/paveg"],"categories":[],"sub_categories":[],"readme":"# hono-problem-details\n\n[![npm version](https://img.shields.io/npm/v/hono-problem-details)](https://www.npmjs.com/package/hono-problem-details)\n[![npm downloads](https://img.shields.io/npm/dw/hono-problem-details)](https://www.npmjs.com/package/hono-problem-details)\n[![bundle size](https://img.shields.io/bundlephobia/minzip/hono-problem-details)](https://bundlephobia.com/package/hono-problem-details)\n[![GitHub stars](https://img.shields.io/github/stars/paveg/hono-problem-details?style=flat)](https://github.com/paveg/hono-problem-details/stargazers)\n[![CI](https://github.com/paveg/hono-problem-details/actions/workflows/ci.yml/badge.svg)](https://github.com/paveg/hono-problem-details/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/paveg/hono-problem-details?utm_source=oss\u0026utm_medium=github\u0026utm_campaign=paveg%2Fhono-problem-details\u0026labelColor=171717\u0026color=FF570A\u0026link=https%3A%2F%2Fcoderabbit.ai\u0026label=CodeRabbit+Reviews)](https://coderabbit.ai)\n[![Devin Wiki](https://img.shields.io/badge/Devin-Wiki-blue)](https://app.devin.ai/org/ryota-ikezawa/wiki/paveg/hono-problem-details)\n\n[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) Problem Details middleware for [Hono](https://hono.dev).\n\nReturns `application/problem+json` structured error responses with a single `app.onError` setup.\n\n\u003e If this saved you from hand-rolling RFC 9457 in yet another Hono project, please [⭐ star the repo](https://github.com/paveg/hono-problem-details) — it helps others discover it.\n\n## Why hono-problem-details?\n\nWithout a contract, HTTP error bodies drift. Every Hono project ends up reinventing the same\nscaffolding — and every client ends up parsing whatever shows up.\n\n- **Inconsistent shapes** across routes: `{ message }`, `{ error }`, `{ code, reason }`, or raw text\n- **Validation errors** from each schema library return a different format, so clients special-case each\n- **OpenAPI drift**: docs describe one error shape, the server returns another\n- **No standard for extensions**: adding `retryAfter` or `correlationId` means breaking your own contract\n\n[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) defines one structure — `{ type, status, title,\ndetail, instance }` plus arbitrary extension members — and this middleware makes it the default for every\nerror in your Hono app: thrown `ProblemDetailsError`, `HTTPException`, validation failures, and unhandled\nexceptions alike. One `app.onError()` line, one contract your clients, OpenAPI spec, and integration tests\ncan all agree on.\n\n## Features\n\n- **RFC 9457 compliant** — `type`, `status`, `title`, `detail`, `instance` + extension members (flattened per §3.1, standard fields always win)\n- **Hono native** — `app.onError` handler with RFC-compliant defaults\n- **Zod integration** — `@hono/zod-validator` hook for validation errors\n- **Valibot integration** — `@hono/valibot-validator` hook for validation errors\n- **OpenAPI integration** — `@hono/zod-openapi` schemas for API documentation\n- **Standard Schema** — `@hono/standard-validator` hook (works with any schema library)\n- **Type-safe** — full TypeScript support with inference\n- **Zero runtime dependencies** — `hono` is the only required peer dependency; validator integrations are optional\n- **Localization** — `localize` callback for title/detail translation\n- **Edge-first** — works on Cloudflare Workers, Deno, Bun, and Node.js\n\n## Install\n\n```bash\nnpm install hono-problem-details\n```\n\n## Quick Start\n\n```ts\nimport { Hono } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { problemDetailsHandler } from \"hono-problem-details\";\n\nconst app = new Hono();\n\napp.onError(problemDetailsHandler());\n\napp.get(\"/not-found\", (c) =\u003e {\n  throw new HTTPException(404, { message: \"Resource not found\" });\n});\n\n// Response:\n// HTTP/1.1 404 Not Found\n// Content-Type: application/problem+json\n// {\n//   \"type\": \"about:blank\",\n//   \"status\": 404,\n//   \"title\": \"Not Found\",\n//   \"detail\": \"Resource not found\"\n// }\n```\n\n## Patterns\n\nCommon error shapes for day-to-day API work. Validation errors are covered separately by the\n[Zod](#zod-validator-integration) / [Valibot](#valibot-validator-integration) / [Standard Schema](#standard-schema-integration)\nhooks — this section is for errors you throw yourself.\n\n```ts\nimport { problemDetails } from \"hono-problem-details\";\n```\n\n### Unauthorized — 401\n\n```ts\nthrow problemDetails({\n  status: 401,\n  title: \"Unauthorized\",\n  detail: \"Missing or invalid credentials\",\n  type: \"https://api.example.com/problems/unauthorized\",\n});\n```\n\nClients key off `type` to trigger a re-auth flow — no need to parse `detail`.\n\n### Forbidden — 403\n\n```ts\nthrow problemDetails({\n  status: 403,\n  title: \"Forbidden\",\n  detail: `User ${userId} cannot access resource ${resourceId}`,\n  type: \"https://api.example.com/problems/forbidden\",\n  extensions: { requiredRole: \"admin\" },\n});\n```\n\n### Not Found — 404\n\n```ts\nthrow problemDetails({\n  status: 404,\n  title: \"Not Found\",\n  detail: `Order ${orderId} does not exist`,\n  instance: `/orders/${orderId}`,\n});\n```\n\n`instance` points at the specific occurrence — clients can use it as a key for retry logic\nor deduplication.\n\n\u003e **Auto-fill shortcuts.** `title` is optional — when omitted, the standard HTTP reason phrase\n\u003e for `status` is used (`404` → `\"Not Found\"`). Similarly, `instance` can be populated from\n\u003e the request path automatically via `problemDetailsHandler({ autoInstance: true })`. Both\n\u003e shortcuts skip the boilerplate in the example above; explicit values always win.\n\n### Conflict — 409\n\n```ts\nthrow problemDetails({\n  status: 409,\n  title: \"Order Conflict\",\n  detail: `Order ${orderId} already exists`,\n  type: \"https://api.example.com/problems/order-conflict\",\n  instance: `/orders/${orderId}`,\n});\n```\n\nDomain conflicts should always carry a project-specific `type` URI. `about:blank` is fine for\ngeneric 4xx/5xx but loses its value the moment a client needs to distinguish two conflicts.\n\n### Too Many Requests — 429\n\n```ts\nthrow problemDetails({\n  status: 429,\n  title: \"Too Many Requests\",\n  detail: \"Request quota exceeded\",\n  type: \"https://api.example.com/problems/rate-limited\",\n  extensions: { retryAfter: 60, quota: 1000, remaining: 0 },\n});\n```\n\nRate-limit metadata goes in `extensions` — clients read it straight from the body instead\nof juggling `Retry-After` headers.\n\n## Extension Members\n\nExtension members are flattened to top level per RFC 9457:\n\n```ts\nthrow problemDetails({\n  status: 422,\n  title: \"Validation Error\",\n  extensions: {\n    errors: [\n      { field: \"email\", message: \"must be a valid email\" },\n    ],\n  },\n});\n\n// Response body:\n// {\n//   \"type\": \"about:blank\",\n//   \"status\": 422,\n//   \"title\": \"Validation Error\",\n//   \"errors\": [{ \"field\": \"email\", \"message\": \"must be a valid email\" }]\n// }\n```\n\n## Problem Type Registry\n\nPre-define your API's error types for type-safe error creation:\n\n```ts\nimport { createProblemTypeRegistry } from \"hono-problem-details\";\n\nconst problems = createProblemTypeRegistry({\n  ORDER_CONFLICT: {\n    type: \"https://api.example.com/problems/order-conflict\",\n    status: 409,\n    title: \"Order Conflict\",\n  },\n  RATE_LIMITED: {\n    type: \"https://api.example.com/problems/rate-limited\",\n    status: 429,\n    title: \"Too Many Requests\",\n  },\n});\n\n// Type-safe error creation\napp.post(\"/orders\", (c) =\u003e {\n  throw problems.create(\"ORDER_CONFLICT\", {\n    detail: `Order ${id} already exists`,\n    instance: `/orders/${id}`,\n  });\n});\n\n// With extensions\nthrow problems.create(\"RATE_LIMITED\", {\n  extensions: { retryAfter: 60 },\n});\n```\n\n### When to use the registry vs `problemDetails()`\n\nReach for `createProblemTypeRegistry` when your API has a fixed set of domain errors and you\nwant one source of truth for `type` / `status` / `title`. It pays off the moment the same error\nis thrown from more than one handler — renames and URI changes happen in one place.\n\nUse `problemDetails()` directly for one-off errors, prototypes, or generic 4xx/5xx where\n`about:blank` is the right `type`. RFC 9457 explicitly allows `about:blank` when the HTTP status\ncode alone is enough context — don't force a URI just to have one.\n\n## Zod Validator Integration\n\n```ts\nimport { zValidator } from \"@hono/zod-validator\";\nimport { zodProblemHook } from \"hono-problem-details/zod\";\nimport { z } from \"zod\";\n\nconst schema = z.object({\n  email: z.string().email(),\n  age: z.number().positive(),\n});\n\napp.post(\"/users\", zValidator(\"json\", schema, zodProblemHook()), (c) =\u003e {\n  const data = c.req.valid(\"json\");\n  // ...\n});\n\n// Validation error response:\n// HTTP/1.1 422 Unprocessable Content\n// Content-Type: application/problem+json\n// {\n//   \"type\": \"about:blank\",\n//   \"status\": 422,\n//   \"title\": \"Validation Error\",\n//   \"detail\": \"Request validation failed\",\n//   \"errors\": [{ \"field\": \"email\", \"message\": \"Invalid email\", \"code\": \"invalid_string\" }]\n// }\n```\n\n## Valibot Validator Integration\n\n```ts\nimport { vValidator } from \"@hono/valibot-validator\";\nimport { valibotProblemHook } from \"hono-problem-details/valibot\";\nimport * as v from \"valibot\";\n\nconst schema = v.object({\n  email: v.pipe(v.string(), v.email()),\n  age: v.pipe(v.number(), v.minValue(1)),\n});\n\napp.post(\"/users\", vValidator(\"json\", schema, valibotProblemHook()), (c) =\u003e {\n  const data = c.req.valid(\"json\");\n  // ...\n});\n```\n\n## Standard Schema Integration\n\nWorks with any [Standard Schema](https://standardschema.dev/) compatible library (Zod, Valibot, ArkType, etc.):\n\n```ts\nimport { sValidator } from \"@hono/standard-validator\";\nimport { standardSchemaProblemHook } from \"hono-problem-details/standard-schema\";\nimport { z } from \"zod\"; // or valibot, arktype, etc.\n\nconst schema = z.object({\n  email: z.string().email(),\n});\n\napp.post(\"/users\", sValidator(\"json\", schema, standardSchemaProblemHook()), (c) =\u003e {\n  const data = c.req.valid(\"json\");\n  // ...\n});\n```\n\n## OpenAPI Integration\n\nUse with `@hono/zod-openapi` to document Problem Details error responses in your OpenAPI spec:\n\n```ts\nimport { OpenAPIHono, createRoute, z } from \"@hono/zod-openapi\";\nimport { problemDetailsHandler } from \"hono-problem-details\";\nimport {\n  ProblemDetailsSchema,\n  createProblemDetailsSchema,\n  problemDetailsResponse,\n} from \"hono-problem-details/openapi\";\n\nconst app = new OpenAPIHono();\napp.onError(problemDetailsHandler());\n\n// Use problemDetailsResponse() in route definitions\nconst route = createRoute({\n  method: \"get\",\n  path: \"/users/{id}\",\n  request: {\n    params: z.object({ id: z.string() }),\n  },\n  responses: {\n    200: {\n      content: {\n        \"application/json\": {\n          schema: z.object({ id: z.string(), name: z.string() }),\n        },\n      },\n      description: \"User found\",\n    },\n    404: problemDetailsResponse(404),\n    422: problemDetailsResponse(422, \"Validation Error\"),\n  },\n});\n\n// With extension members\nconst errorWithExtensions = createProblemDetailsSchema(\n  z.object({\n    errors: z.array(z.object({ field: z.string(), message: z.string() })),\n  }),\n);\n// Use: problemDetailsResponse(422, \"Validation Error\", errorWithExtensions)\n```\n\n## Localization\n\nUse the `localize` callback to translate `title` and `detail` based on the request context.\nReturn a partial patch with just the fields you want to override — everything else falls\nthrough unchanged. Returning nothing (or `undefined`) leaves the response untouched.\n\n```ts\nproblemDetailsHandler({\n  localize: (pd, c) =\u003e {\n    const lang = c.req.header(\"Accept-Language\");\n    if (lang?.startsWith(\"ja\")) {\n      return { title: translate(\"ja\", pd.title) };\n    }\n    return pd;\n  },\n});\n```\n\nThe callback receives the fully-built `ProblemDetails` object and the Hono `Context`, allowing access to headers like `Accept-Language`. Return a new `ProblemDetails` with translated fields.\n\n\u003e **Note on caching**: If your responses vary by `Accept-Language`, add `Vary: Accept-Language`\n\u003e from your own middleware so CDNs and browser caches don't serve the wrong translation.\n\u003e This middleware intentionally does not set `Vary` — error handlers shouldn't mutate\n\u003e request-scope headers that also apply to successful responses.\n\n\u003e **Note on failures**: If your `localize` callback throws, the handler falls back to the\n\u003e un-localized `ProblemDetails` and continues. Throwing from inside `app.onError` would cause\n\u003e the error handler to re-enter itself, so the swallow is deliberate. Catch errors inside your\n\u003e callback if you need to observe them.\n\n## Handler Options\n\n```ts\nproblemDetailsHandler({\n  // Prefix for type URI (e.g., \"https://api.example.com/problems\")\n  typePrefix: \"https://api.example.com/problems\",\n\n  // Default type URI (default: \"about:blank\")\n  defaultType: \"about:blank\",\n\n  // Include stack trace in `extensions.stack` on 500 responses (development only)\n  includeStack: process.env.NODE_ENV === \"development\",\n\n  // Populate `instance` from `c.req.path` when the thrown problem didn't specify one\n  autoInstance: true,\n\n  // Localize title/detail before sending the response.\n  // Return a partial patch — fields you omit fall through from the original.\n  localize: (pd, c) =\u003e {\n    const lang = c.req.header(\"Accept-Language\") ?? \"en\";\n    return { title: `[${lang}] ${pd.title}` };\n  },\n\n  // Custom error mapping\n  mapError: (error) =\u003e {\n    if (error instanceof MyCustomError) {\n      return {\n        status: error.statusCode,\n        title: error.name,\n        detail: error.message,\n      };\n    }\n    return undefined; // fallback to default handling\n  },\n});\n```\n\n## Used By\n\nThe following Hono middleware libraries use `hono-problem-details` as an optional dependency for RFC 9457 error responses:\n\n- [hono-idempotency](https://github.com/paveg/hono-idempotency) — Idempotency key middleware for Hono\n- [hono-webhook-verify](https://github.com/paveg/hono-webhook-verify) — Webhook signature verification middleware for Hono\n- [hono-cf-access](https://github.com/paveg/hono-cf-access) — Country / ASN blocking and maintenance mode via Cloudflare Workers `request.cf`\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaveg%2Fhono-problem-details","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpaveg%2Fhono-problem-details","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaveg%2Fhono-problem-details/lists"}