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:
parent
b2b598c6c8
commit
de150e790d
2 changed files with 118 additions and 44 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue