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",
"version": "0.3.1",
"version": "0.3.2",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module",
"bin": {

View file

@ -2,62 +2,106 @@
//
// Orval's `client: 'fetch'` emits functions that call
// coreFetch<T>(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/<name>/` 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<string>;
/**
* 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 `"<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). */
fetchImpl?: typeof fetch;
}
export type CoreFetch = <T extends { data: unknown; status: number }>(
export type CoreFetchWrapped = <T extends { data: unknown; status: number }>(
url: string,
init?: RequestInit,
) => 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']);
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<T extends { data: unknown; status: number }>(
return async function coreFetch<T>(
url: string,
init: RequestInit = {},
): Promise<T> {
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;
}