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:
parent
55968fbd73
commit
69aec0f86c
3 changed files with 135 additions and 4 deletions
21
README.md
21
README.md
|
|
@ -43,8 +43,25 @@ webapp-scaffold-postprocess-openapi && \
|
|||
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
|
||||
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@uschuster/webapp-scaffold",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
},
|
||||
"main": "src/vite-config.ts",
|
||||
"exports": {
|
||||
".": "./src/vite-config.ts",
|
||||
".": "./src/vite-config.ts",
|
||||
"./core-fetch": "./src/core-fetch.ts",
|
||||
"./orval-template": "./templates/orval.config.template.ts"
|
||||
},
|
||||
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],
|
||||
|
|
|
|||
113
src/core-fetch.ts
Normal file
113
src/core-fetch.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue