From de150e790de8ea10ac2a50d818548e820793e47f Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Tue, 21 Apr 2026 22:27:30 +0200 Subject: [PATCH] =?UTF-8?q?v0.3.2:=20createCoreFetch=20=E2=80=94=20respons?= =?UTF-8?q?eShape=20+=20network-failure=20+=20formatError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New options to fit fewo's core-fetch contract: responseShape: 'wrapped' | 'body' — 'body' matches orval's includeHttpResponseReturnType:false onNetworkFailure(req, error) — return a value to swallow fetch() rejection; enables online-first + enqueue-on-offline pattern (fewo) formatError(req, resp, text, json) — override default error message format (needed for localized 401/403 messages, conflict-body decoration) 409 errors throw an Error decorated with .status = 409 and .body = parsed JSON so conflict resolvers at call sites can still inspect them. Backwards-compatible: new options default to prior behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/core-fetch.ts | 160 +++++++++++++++++++++++++++++++++------------- 2 files changed, 118 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index c4467cc..973c243 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@uschuster/webapp-scaffold", - "version": "0.3.1", + "version": "0.3.2", "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 54eb286..c976ecb 100644 --- a/src/core-fetch.ts +++ b/src/core-fetch.ts @@ -2,62 +2,106 @@ // // Orval's `client: 'fetch'` emits functions that call // coreFetch(url, init) -// and expect `{ data, status, headers }` back. This factory returns such a -// function, with the baseline behaviour shared across projects: +// and expect one of: +// • { data, status, headers } back (responseShape: 'wrapped', default — +// matches orval's default `includeHttpResponseReturnType: true`) +// • the parsed body directly (responseShape: 'body' — matches orval's +// `includeHttpResponseReturnType: false`, fewo's convention) // -// • credentials: 'include' — cookies always sent -// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard -// • 204 No Content → data: undefined — no JSON parse attempted +// Baseline wired in: +// • credentials: 'include' — cookies always sent +// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard +// • 204 No Content → data: undefined — no JSON parse attempted // • JSON content-type → JSON.parse, else text // -// Hooks let projects plug in the app-specific concerns (sync queue, 401 -// redirect, 409 conflict resolution) without forking the mutator. +// Hooks let projects plug in app-specific concerns (sync queue, 401 redirect, +// 409 conflict, network-failure offline fallback, localized error messages) +// without forking the mutator. + +export type ResponseShape = 'wrapped' | 'body'; + +export interface CoreFetchRequest { + url: string; + init: RequestInit; +} export interface CoreFetchOptions { - /** - * Base path prefix for every request. Typically `BASE_URL` from the - * bundler (strip the trailing slash): projects behind an Apache - * `/projects//` prefix set this to that path; root-hosted apps - * leave it empty. - */ + /** Prefix for every request; strip trailing slash. */ baseUrl?: string; + /** `'wrapped'` returns { data, status, headers } (default); `'body'` returns the parsed body. */ + responseShape?: ResponseShape; + /** - * Tables whose mutations should route through an offline sync queue - * instead of executing directly. The mutator calls `onEnqueue` when - * the request's method is mutating AND the URL matches one of these - * tables; if that returns `true` the request is considered satisfied - * and the factory fabricates a `{status: 202, data: null}` response. + * Tables whose mutations route through an offline sync queue ON NETWORK + * FAILURE only — i.e. "online-first, enqueue-on-fail". Pre-emptive + * always-queue behaviour: set `onEnqueue` to return true directly. */ syncTables?: ReadonlySet; /** - * Enqueue hook — called with the outgoing request. Return `true` to - * skip the network call (the request was queued). Return `false` to - * proceed with the real fetch. + * Pre-emptive enqueue hook — called BEFORE the network attempt, for + * mutating requests on matching sync tables. Return `true` to skip the + * network call (the request was queued); `false` to proceed. */ - onEnqueue?: (req: { url: string; init: RequestInit }) => boolean; + onEnqueue?: (req: CoreFetchRequest) => boolean; + + /** + * Network-failure hook — called when fetch() itself rejects (e.g. offline). + * Return a value to swallow the rejection and use that value as the + * response body (returned directly in `'body'` shape, or wrapped in + * `{ data, status: 202, headers: {} }` in `'wrapped'` shape). Return + * `undefined` (the default) to rethrow. Useful for fewo's + * "enqueue mutation on offline, return synthetic ok" pattern. + */ + onNetworkFailure?: (req: CoreFetchRequest, error: unknown) => unknown; /** Called after any 401 response, before the error is thrown. */ - on401?: (req: { url: string; init: RequestInit }) => void; + on401?: (req: CoreFetchRequest) => void; - /** Called after any 409 response; return a resolved value to swallow the error. */ - on409?: (req: { url: string; init: RequestInit }, body: string) => unknown; + /** + * Called after any 409 response; return a resolved value to swallow + * the error (returned as the response body), or undefined to throw. + */ + on409?: (req: CoreFetchRequest, body: string) => unknown; + + /** + * Custom error message formatter. Called for any non-OK response + * (after on401 / on409 have run and didn't swallow). Return a string + * to use as the Error message; return undefined to fall back to the + * default `" : "` format. Also receives the + * parsed body when it's valid JSON (otherwise `null`). + */ + formatError?: ( + req: CoreFetchRequest, + response: Response, + bodyText: string, + bodyJson: unknown | null, + ) => string | undefined; /** Swap out the fetch impl (tests). */ fetchImpl?: typeof fetch; } -export type CoreFetch = ( +export type CoreFetchWrapped = ( url: string, init?: RequestInit, ) => Promise; +export type CoreFetchBody = (url: string, init?: RequestInit) => Promise; + +export type CoreFetch = CoreFetchWrapped & CoreFetchBody; + const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch { const base = (opts.baseUrl ?? '').replace(/\/$/, ''); const fetchImpl = opts.fetchImpl ?? fetch; + const shape: ResponseShape = opts.responseShape ?? 'wrapped'; + + function wrap(data: unknown, status: number, headers: Headers): unknown { + return shape === 'body' ? data : { data, status, headers }; + } function matchesSyncTable(url: string): boolean { if (!opts.syncTables || opts.syncTables.size === 0) return false; @@ -67,40 +111,59 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch { return false; } - return async function coreFetch( + return async function coreFetch( url: string, init: RequestInit = {}, ): Promise { const method = (init.method ?? 'GET').toUpperCase(); const headers = new Headers(init.headers); headers.set('X-Requested-With', 'XMLHttpRequest'); + const req: CoreFetchRequest = { url, init: { ...init, headers } }; - // Offline sync path. + // Pre-emptive enqueue (always-queue pattern). if (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) { - if (opts.onEnqueue({ url, init: { ...init, headers } })) { - return { data: null, status: 202, headers: new Headers() } as unknown as T; + if (opts.onEnqueue(req)) { + return wrap(null, 202, new Headers()) as T; } } - const r = await fetchImpl(`${base}${url}`, { - credentials: 'include', - ...init, - headers, - }); + let r: Response; + try { + r = await fetchImpl(`${base}${url}`, { + credentials: 'include', + ...init, + headers, + }); + } catch (networkErr) { + if (opts.onNetworkFailure) { + const replacement = opts.onNetworkFailure(req, networkErr); + if (replacement !== undefined) { + return wrap(replacement, 0, new Headers()) as T; + } + } + throw networkErr; + } - if (r.status === 401) opts.on401?.({ url, init }); + if (r.status === 401) opts.on401?.(req); if (r.status === 409 && opts.on409) { const body = await r.text(); - const resolved = opts.on409({ url, init }, body); + const resolved = opts.on409(req, body); if (resolved !== undefined) { - return { data: resolved, status: r.status, headers: r.headers } as unknown as T; + return wrap(resolved, r.status, r.headers) as T; } - throw new Error(`${r.status} ${r.statusText}: ${body}`); + // 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); } if (!r.ok) { - const text = await r.text(); - throw new Error(`${r.status} ${r.statusText}: ${text}`); + const body = await r.text(); + const bodyJson = safeJson(body); + const msg = opts.formatError?.(req, r, body, bodyJson) + ?? `${r.status} ${r.statusText}: ${body}`; + throw new Error(msg); } let data: unknown = undefined; @@ -108,6 +171,17 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch { const ct = r.headers.get('content-type') ?? ''; data = ct.includes('application/json') ? await r.json() : await r.text(); } - return { data, status: r.status, headers: r.headers } as unknown as T; - }; + return wrap(data, r.status, r.headers) as T; + } as CoreFetch; +} + +function safeJson(text: string): unknown | null { + try { return JSON.parse(text); } catch { return null; } +} + +/** 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; + (err as Error & { status?: number; body?: unknown }).body = body; + return err; }