v0.3.2: createCoreFetch — responseShape + network-failure + formatError

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) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 22:27:30 +02:00
parent b2b598c6c8
commit de150e790d
2 changed files with 118 additions and 44 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "@uschuster/webapp-scaffold", "name": "@uschuster/webapp-scaffold",
"version": "0.3.1", "version": "0.3.2",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.", "description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module", "type": "module",
"bin": { "bin": {

View file

@ -2,62 +2,106 @@
// //
// Orval's `client: 'fetch'` emits functions that call // Orval's `client: 'fetch'` emits functions that call
// coreFetch<T>(url, init) // coreFetch<T>(url, init)
// and expect `{ data, status, headers }` back. This factory returns such a // and expect one of:
// function, with the baseline behaviour shared across projects: // • { 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 // Baseline wired in:
// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard // • credentials: 'include' — cookies always sent
// • 204 No Content → data: undefined — no JSON parse attempted // • 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 // • JSON content-type → JSON.parse, else text
// //
// Hooks let projects plug in the app-specific concerns (sync queue, 401 // Hooks let projects plug in app-specific concerns (sync queue, 401 redirect,
// redirect, 409 conflict resolution) without forking the mutator. // 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 { export interface CoreFetchOptions {
/** /** Prefix for every request; strip trailing slash. */
* Base path prefix for every request. Typically `BASE_URL` from the
* bundler (strip the trailing slash): projects behind an Apache
* `/projects/<name>/` prefix set this to that path; root-hosted apps
* leave it empty.
*/
baseUrl?: string; 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 * Tables whose mutations route through an offline sync queue ON NETWORK
* instead of executing directly. The mutator calls `onEnqueue` when * FAILURE only i.e. "online-first, enqueue-on-fail". Pre-emptive
* the request's method is mutating AND the URL matches one of these * always-queue behaviour: set `onEnqueue` to return true directly.
* tables; if that returns `true` the request is considered satisfied
* and the factory fabricates a `{status: 202, data: null}` response.
*/ */
syncTables?: ReadonlySet<string>; syncTables?: ReadonlySet<string>;
/** /**
* Enqueue hook called with the outgoing request. Return `true` to * Pre-emptive enqueue hook called BEFORE the network attempt, for
* skip the network call (the request was queued). Return `false` to * mutating requests on matching sync tables. Return `true` to skip the
* proceed with the real fetch. * 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. */ /** 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 `"<status> <statusText>: <body>"` 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). */ /** Swap out the fetch impl (tests). */
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
} }
export type CoreFetch = <T extends { data: unknown; status: number }>( export type CoreFetchWrapped = <T extends { data: unknown; status: number }>(
url: string, url: string,
init?: RequestInit, init?: RequestInit,
) => Promise<T>; ) => Promise<T>;
export type CoreFetchBody = <T>(url: string, init?: RequestInit) => Promise<T>;
export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch { export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const base = (opts.baseUrl ?? '').replace(/\/$/, ''); const base = (opts.baseUrl ?? '').replace(/\/$/, '');
const fetchImpl = opts.fetchImpl ?? fetch; 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 { function matchesSyncTable(url: string): boolean {
if (!opts.syncTables || opts.syncTables.size === 0) return false; if (!opts.syncTables || opts.syncTables.size === 0) return false;
@ -67,40 +111,59 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
return false; return false;
} }
return async function coreFetch<T extends { data: unknown; status: number }>( return async function coreFetch<T>(
url: string, url: string,
init: RequestInit = {}, init: RequestInit = {},
): Promise<T> { ): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase(); const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set('X-Requested-With', 'XMLHttpRequest'); 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 (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) {
if (opts.onEnqueue({ url, init: { ...init, headers } })) { if (opts.onEnqueue(req)) {
return { data: null, status: 202, headers: new Headers() } as unknown as T; return wrap(null, 202, new Headers()) as T;
} }
} }
const r = await fetchImpl(`${base}${url}`, { let r: Response;
credentials: 'include', try {
...init, r = await fetchImpl(`${base}${url}`, {
headers, 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) { if (r.status === 409 && opts.on409) {
const body = await r.text(); const body = await r.text();
const resolved = opts.on409({ url, init }, body); const resolved = opts.on409(req, body);
if (resolved !== undefined) { 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) { if (!r.ok) {
const text = await r.text(); const body = await r.text();
throw new Error(`${r.status} ${r.statusText}: ${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; let data: unknown = undefined;
@ -108,6 +171,17 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const ct = r.headers.get('content-type') ?? ''; const ct = r.headers.get('content-type') ?? '';
data = ct.includes('application/json') ? await r.json() : await r.text(); 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;
} }