From 0673c4c0d810c3e185b065c96a5ce7ab2c7d38ee Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Tue, 21 Apr 2026 22:31:56 +0200 Subject: [PATCH] v0.3.4: error path prefers .json() with .text() fallback; always decorate 409 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the common test pattern of mocking .json() for error responses (vitest helpers often do that by default and don't bother with .text()). The error path now clones the response, tries .json() first, and falls back to .text() on parse failure. Populates both text and json for formatError callers. 409 errors are always decorated with .status=409 and .body=, even when the consumer doesn't provide on409 — conflict resolvers at call sites can still reach the payload. Default 409 message is the body's .message field if present; otherwise falls through to the usual format. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/core-fetch.ts | 45 +++++++++++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index e360941..159bbd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@uschuster/webapp-scaffold", - "version": "0.3.3", + "version": "0.3.4", "description": "Shared build scripts + Vite config factories for webapp-template-derived projects.", "type": "module", "bin": { diff --git a/src/core-fetch.ts b/src/core-fetch.ts index 3f6fd34..a165531 100644 --- a/src/core-fetch.ts +++ b/src/core-fetch.ts @@ -149,24 +149,25 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch { } if (r.status === 401) opts.on401?.(req); - if (r.status === 409 && opts.on409) { - const body = await r.text(); - const resolved = opts.on409(req, body); - if (resolved !== undefined) { - return wrap(resolved, r.status, r.headers) as T; + if (r.status === 409) { + const [text, json] = await readBody(r); + if (opts.on409) { + const resolved = opts.on409(req, text); + if (resolved !== undefined) { + return wrap(resolved, r.status, r.headers) as T; + } } - // Fall through to error-formatting path below. - const bodyJson = safeJson(body); - const msg = opts.formatError?.(req, r, body, bodyJson) - ?? `${r.status} ${r.statusText}: ${body}`; - throw decorate409(new Error(msg), bodyJson); + const msg = opts.formatError?.(req, r, text, json) + ?? (json && typeof (json as Record).message === 'string' + ? String((json as Record).message) + : `${r.status} ${r.statusText}: ${text}`); + throw decorate409(new Error(msg), json); } if (!r.ok) { - const body = await r.text(); - const bodyJson = safeJson(body); - const msg = opts.formatError?.(req, r, body, bodyJson) - ?? `${r.status} ${r.statusText}: ${body}`; + const [text, json] = await readBody(r); + const msg = opts.formatError?.(req, r, text, json) + ?? `${r.status} ${r.statusText}: ${text}`; throw new Error(msg); } @@ -183,6 +184,22 @@ function safeJson(text: string): unknown | null { try { return JSON.parse(text); } catch { return null; } } +/** + * Read an error-response body. Prefer `.json()` (tests commonly mock only + * that); fall back to `.text()` if json() throws. Returns both the raw + * text (for `formatError` callers that want to log it) and the parsed + * JSON (may be null when the body isn't parseable). + */ +async function readBody(r: Response): Promise<[string, unknown | null]> { + try { + const j = await r.clone().json(); + return [JSON.stringify(j), j]; + } catch { + const text = await r.text().catch(() => ''); + return [text, safeJson(text)]; + } +} + /** Attach status/body to 409 errors so conflict-resolvers can reach them. */ function decorate409(err: Error, body: unknown): Error { (err as Error & { status?: number; body?: unknown }).status = 409;