v0.2.0: add createCoreFetch factory

Orval-mutator factory for derived projects. Baseline behaviour
(credentials, X-Requested-With, 204/JSON handling) baked in; four
hooks let consumers wire the app-specific concerns without forking:

  baseUrl      — Apache-proxy prefix (empty for root-hosted apps)
  syncTables   — table names that route mutations through a sync queue
  onEnqueue    — queue callback (returns true ⇒ skip network, 202 back)
  on401        — session-invalidation redirect
  on409        — conflict resolver (return value swallows the error)
  fetchImpl    — test injection

Typechecks clean with tsc --strict against ES2020/DOM lib. Exported as
'@uschuster/webapp-scaffold/core-fetch' so consumers import only what
they need.

Closes fewo-webapp#415

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 22:09:08 +02:00
parent 55968fbd73
commit 69aec0f86c
3 changed files with 135 additions and 4 deletions

View file

@ -43,8 +43,25 @@ webapp-scaffold-postprocess-openapi && \
orval orval
``` ```
## `createCoreFetch` (v0.2)
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns
such a function, with credentials/CSRF headers / 204 / content-type
handling already wired, and hooks for project-specific concerns:
```ts
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';
export const coreFetch = createCoreFetch({
baseUrl: import.meta.env.BASE_URL,
syncTables: new Set(['bookings', 'persons', 'contacts']),
onEnqueue: (req) => syncQueue.push(req), // returns true ⇒ skip network
on401: () => window.location.assign('/admin'),
on409: (_req, body) => ({ conflict: JSON.parse(body) }),
});
```
## Roadmap ## Roadmap
- **v0.2**`createCoreFetch` factory (the Orval-mutator `coreFetch` wrapper,
lifted from webapp-template, extended with `syncTables` + conflict hooks).
- **v0.3** — i18n system (de/en × formal/informal with fallback chain). - **v0.3** — i18n system (de/en × formal/informal with fallback chain).

View file

@ -1,6 +1,6 @@
{ {
"name": "@uschuster/webapp-scaffold", "name": "@uschuster/webapp-scaffold",
"version": "0.1.0", "version": "0.2.0",
"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": {
@ -10,7 +10,8 @@
}, },
"main": "src/vite-config.ts", "main": "src/vite-config.ts",
"exports": { "exports": {
".": "./src/vite-config.ts", ".": "./src/vite-config.ts",
"./core-fetch": "./src/core-fetch.ts",
"./orval-template": "./templates/orval.config.template.ts" "./orval-template": "./templates/orval.config.template.ts"
}, },
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"], "files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],

113
src/core-fetch.ts Normal file
View file

@ -0,0 +1,113 @@
// createCoreFetch — factory for the Orval `fetch` mutator used by derived projects.
//
// 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:
//
// • 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.
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.
*/
baseUrl?: string;
/**
* 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.
*/
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.
*/
onEnqueue?: (req: { url: string; init: RequestInit }) => boolean;
/** Called after any 401 response, before the error is thrown. */
on401?: (req: { url: string; init: RequestInit }) => void;
/** Called after any 409 response; return a resolved value to swallow the error. */
on409?: (req: { url: string; init: RequestInit }, body: string) => unknown;
/** Swap out the fetch impl (tests). */
fetchImpl?: typeof fetch;
}
export type CoreFetch = <T extends { data: unknown; status: number }>(
url: string,
init?: RequestInit,
) => Promise<T>;
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
const fetchImpl = opts.fetchImpl ?? fetch;
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<T extends { data: unknown; status: number }>(
url: string,
init: RequestInit = {},
): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers);
headers.set('X-Requested-With', 'XMLHttpRequest');
// Offline sync path.
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;
}
}
const r = await fetchImpl(`${base}${url}`, {
credentials: 'include',
...init,
headers,
});
if (r.status === 401) opts.on401?.({ url, init });
if (r.status === 409 && opts.on409) {
const body = await r.text();
const resolved = opts.on409({ url, init }, body);
if (resolved !== undefined) {
return { data: resolved, status: r.status, headers: r.headers } as unknown as T;
}
throw new Error(`${r.status} ${r.statusText}: ${body}`);
}
if (!r.ok) {
const text = await r.text();
throw new Error(`${r.status} ${r.statusText}: ${text}`);
}
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 { data, status: r.status, headers: r.headers } as unknown as T;
};
}