// createCoreFetch — factory for the Orval `fetch` mutator used by derived projects. // // Orval's `client: 'fetch'` emits functions that call // coreFetch(url, init) // and expect one of: // • the parsed body directly (responseShape: 'body', DEFAULT — matches // orval's `includeHttpResponseReturnType: false`, the Orval-driven // "no extra ceremony" path) // • { data, status, headers } back (responseShape: 'wrapped' — matches // orval's `includeHttpResponseReturnType: true`; opt in when callers // need the Response metadata) // // 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 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 { /** Prefix for every request; strip trailing slash. */ baseUrl?: string; /** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */ responseShape?: ResponseShape; /** * 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; /** * 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: 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: CoreFetchRequest) => void; /** * 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 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(/\/$/, ''); // Resolve fetch at call time, not construction time, so tests that // `vi.stubGlobal('fetch', ...)` after module import see the stub. const callFetch: typeof fetch = opts.fetchImpl ? opts.fetchImpl : ((...args) => fetch(...(args as Parameters))) as typeof fetch; const shape: ResponseShape = opts.responseShape ?? 'body'; 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; for (const t of opts.syncTables) { if (url.includes(`/api/${t}`) || url.includes(`/${t}/`)) return true; } return false; } 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 } }; // Pre-emptive enqueue (always-queue pattern). if (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) { if (opts.onEnqueue(req)) { return wrap(null, 202, new Headers()) as T; } } let r: Response; try { r = await callFetch(`${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?.(req); 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; } } 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 [text, json] = await readBody(r); const msg = opts.formatError?.(req, r, text, json) ?? `${r.status} ${r.statusText}: ${text}`; throw new Error(msg); } let data: unknown = undefined; if (r.status !== 204) { const ct = r.headers.get('content-type') ?? ''; data = ct.includes('application/json') ? await r.json() : await r.text(); } 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; } } /** * 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]> { // Some mocks expose .json() but not .clone() (e.g. vitest object mocks). // Try .json() directly; if it throws, fall back to .text(). try { const j = await r.json(); return [JSON.stringify(j), j]; } catch { try { const text = await r.text(); return [text, safeJson(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; }