From 69aec0f86c413b30b8e4bd3298262bb076a98c34 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Tue, 21 Apr 2026 22:09:08 +0200 Subject: [PATCH] v0.2.0: add createCoreFetch factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 21 ++++++++- package.json | 5 +- src/core-fetch.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/core-fetch.ts diff --git a/README.md b/README.md index 1786e40..aaf34c3 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,25 @@ webapp-scaffold-postprocess-openapi && \ orval ``` +## `createCoreFetch` (v0.2) + +Orval's `client: 'fetch'` calls a mutator `coreFetch(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). diff --git a/package.json b/package.json index 623832d..ad1bc13 100644 --- a/package.json +++ b/package.json @@ -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"], diff --git a/src/core-fetch.ts b/src/core-fetch.ts new file mode 100644 index 0000000..54eb286 --- /dev/null +++ b/src/core-fetch.ts @@ -0,0 +1,113 @@ +// createCoreFetch — factory for the Orval `fetch` mutator used by derived projects. +// +// Orval's `client: 'fetch'` emits functions that call +// coreFetch(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//` 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; + + /** + * 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 = ( + url: string, + init?: RequestInit, +) => Promise; + +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( + url: string, + init: RequestInit = {}, + ): Promise { + 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; + }; +}