@xemahq/api-client-generator 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/bin/xema-openapi.js +2 -2
  2. package/dist/bin/xema-openapi.js.map +1 -1
  3. package/dist/lib/client-package.d.ts.map +1 -1
  4. package/dist/lib/client-package.js +9 -4
  5. package/dist/lib/client-package.js.map +1 -1
  6. package/dist/lib/default-stubs.d.ts.map +1 -1
  7. package/dist/lib/default-stubs.js +35 -10
  8. package/dist/lib/default-stubs.js.map +1 -1
  9. package/dist/lib/extract-openapi.d.ts +2 -2
  10. package/dist/lib/extract-openapi.d.ts.map +1 -1
  11. package/dist/lib/extract-openapi.js +16 -7
  12. package/dist/lib/extract-openapi.js.map +1 -1
  13. package/dist/lib/generate-clients.d.ts +1 -1
  14. package/dist/lib/generate-clients.d.ts.map +1 -1
  15. package/dist/lib/generate-clients.js +1 -1
  16. package/dist/lib/generate-clients.js.map +1 -1
  17. package/dist/lib/overrides.d.ts.map +1 -1
  18. package/dist/lib/overrides.js +10 -2
  19. package/dist/lib/overrides.js.map +1 -1
  20. package/dist/lib/repo-post-process.d.ts.map +1 -1
  21. package/dist/lib/repo-post-process.js +2 -1
  22. package/dist/lib/repo-post-process.js.map +1 -1
  23. package/dist/lib/seed-openapi-env.d.ts.map +1 -1
  24. package/dist/lib/seed-openapi-env.js +0 -20
  25. package/dist/lib/seed-openapi-env.js.map +1 -1
  26. package/dist/lib/service-config.d.ts +1 -1
  27. package/dist/lib/service-config.d.ts.map +1 -1
  28. package/dist/lib/service-config.js +2 -2
  29. package/dist/lib/service-config.js.map +1 -1
  30. package/dist/lib/service-nest.d.ts.map +1 -1
  31. package/dist/lib/service-nest.js.map +1 -1
  32. package/package.json +5 -3
  33. package/src/bin/xema-client-generate.ts +11 -0
  34. package/src/bin/xema-openapi.ts +51 -0
  35. package/src/index.ts +9 -0
  36. package/src/lib/client-package.ts +215 -0
  37. package/src/lib/client-readme.ts +124 -0
  38. package/src/lib/custom-fetch-template.ts +200 -0
  39. package/src/lib/default-stubs.ts +422 -0
  40. package/src/lib/extract-openapi.ts +240 -0
  41. package/src/lib/generate-clients.ts +240 -0
  42. package/src/lib/overrides.ts +81 -0
  43. package/src/lib/repo-post-process.ts +106 -0
  44. package/src/lib/seed-openapi-env.ts +137 -0
  45. package/src/lib/service-config.ts +170 -0
  46. package/src/lib/service-nest.ts +58 -0
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `xema-client-generate` — generate (and compile) one ORVAL client package per
4
+ * surface that has an `openapi.<surface>.json` spec in the current working
5
+ * directory. Run AFTER `xema-openapi`.
6
+ *
7
+ * Service package.json: `"client:generate": "xema-client-generate"`.
8
+ */
9
+ import { runClientGenerateCli } from '../lib/generate-clients';
10
+
11
+ runClientGenerateCli();
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `xema-openapi` — extract one OpenAPI document per ApiSurface from the NestJS
4
+ * service in the current working directory.
5
+ *
6
+ * Run from a service dir (where package.json carries `xema.api`). Self-registers
7
+ * ts-node + tsconfig-paths from the SERVICE's own toolchain so the service's TS
8
+ * `AppModule` compiles on import, then delegates to the generator.
9
+ *
10
+ * Service package.json: `"openapi": "NODE_PATH=node_modules xema-openapi"`.
11
+ */
12
+ // `node:module` is a side-effect-free builtin, so a static import is
13
+ // equivalent to the `require()` this used to do (the file compiles to
14
+ // CommonJS) — and it drops an inline `import()` type annotation whose only
15
+ // job was to type that `require`. The DYNAMIC requires below are different:
16
+ // they resolve paths computed at runtime, after `ts-node.register()` has
17
+ // patched the require hook, and must stay `require()`.
18
+ import { Module } from 'node:module';
19
+ import { delimiter } from 'node:path';
20
+
21
+ function registerTsNode(cwd: string): void {
22
+ const tsNode = require.resolve('ts-node', { paths: [cwd] });
23
+
24
+ (require(tsNode) as { register: (o: unknown) => void }).register({
25
+ transpileOnly: true,
26
+ });
27
+ try {
28
+ const paths = require.resolve('tsconfig-paths/register', { paths: [cwd] });
29
+ require(paths);
30
+ } catch {
31
+ // tsconfig-paths is optional — services without path aliases don't need it.
32
+ }
33
+ }
34
+
35
+ function ensureNodePath(): void {
36
+ process.env.NODE_PATH = process.env.NODE_PATH
37
+ ? `node_modules${delimiter}${process.env.NODE_PATH}`
38
+ : 'node_modules';
39
+ (Module as unknown as { _initPaths: () => void })._initPaths();
40
+ }
41
+
42
+ ensureNodePath();
43
+ registerTsNode(process.cwd());
44
+
45
+ // Require AFTER ts-node is registered so the service's TS modules compile.
46
+
47
+ const { runOpenApiCli } = require('../lib/extract-openapi') as {
48
+ runOpenApiCli: () => void;
49
+ };
50
+
51
+ runOpenApiCli();
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Public programmatic surface of the central API-client generator.
2
+ export * from './lib/service-config';
3
+ export * from './lib/overrides';
4
+ export * from './lib/seed-openapi-env';
5
+ export * from './lib/default-stubs';
6
+ export * from './lib/extract-openapi';
7
+ export * from './lib/repo-post-process';
8
+ export * from './lib/client-package';
9
+ export * from './lib/generate-clients';
@@ -0,0 +1,215 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ import { ApiSurface } from '@xemahq/xema-decorators';
11
+
12
+ import { CANONICAL_CUSTOM_FETCH } from './custom-fetch-template';
13
+ import {
14
+ surfaceClientDir,
15
+ surfaceClientPackageName,
16
+ type ResolvedServiceContext,
17
+ } from './service-config';
18
+
19
+ /** The authoritative package→Xema mapping stamped into each generated client. */
20
+ export interface GeneratedClientXemaBlock {
21
+ readonly kind: 'api-client';
22
+ readonly surface: ApiSurface;
23
+ readonly service: string;
24
+ readonly biome: string;
25
+ readonly target: 'server';
26
+ readonly generator: string;
27
+ readonly source: string;
28
+ }
29
+
30
+ const CLIENT_TSCONFIG = {
31
+ compilerOptions: {
32
+ target: 'ES2022',
33
+ module: 'commonjs',
34
+ lib: ['ES2022', 'DOM'],
35
+ types: [],
36
+ strict: true,
37
+ esModuleInterop: true,
38
+ skipLibCheck: true,
39
+ declaration: true,
40
+ outDir: './dist',
41
+ rootDir: './src',
42
+ },
43
+ include: ['src/**/*.ts'],
44
+ };
45
+
46
+ function generatorTag(): string {
47
+ const pkg = require('../../package.json') as { version: string };
48
+ return `@xemahq/api-client-generator@${pkg.version}`;
49
+ }
50
+
51
+ function recordOrEmpty(value: unknown): Record<string, unknown> {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
53
+ ? Object.fromEntries(Object.entries(value))
54
+ : {};
55
+ }
56
+
57
+ /** Parse a release semver core `x.y.z` (any prerelease/build suffix ignored). */
58
+ function parseVersionCore(v: string): [number, number, number] | null {
59
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
60
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
61
+ }
62
+
63
+ /**
64
+ * Resolve the client `version` MONOTONICALLY. A published client MUST NEVER be
65
+ * downgraded by regeneration: we keep whichever of the existing client version
66
+ * and the service version is greater. A forward bump happens only when the
67
+ * service version genuinely exceeds the client's (an intentional, monotonic
68
+ * move). If either version is unparseable, the existing version is kept — the
69
+ * fail-safe default is "never downgrade".
70
+ *
71
+ * A fresh client (no existing version) adopts the service version.
72
+ */
73
+ export function resolveMonotonicVersion(
74
+ existing: unknown,
75
+ serviceVersion: string,
76
+ ): string {
77
+ if (typeof existing !== 'string' || existing.trim() === '') {
78
+ return serviceVersion;
79
+ }
80
+ const e = parseVersionCore(existing);
81
+ const s = parseVersionCore(serviceVersion);
82
+ if (!e || !s) {
83
+ return existing;
84
+ }
85
+ const [eMaj, eMin, ePat] = e;
86
+ const [sMaj, sMin, sPat] = s;
87
+ if (sMaj !== eMaj) {
88
+ return sMaj > eMaj ? serviceVersion : existing;
89
+ }
90
+ if (sMin !== eMin) {
91
+ return sMin > eMin ? serviceVersion : existing;
92
+ }
93
+ if (sPat !== ePat) {
94
+ return sPat > ePat ? serviceVersion : existing;
95
+ }
96
+ return existing;
97
+ }
98
+
99
+ /**
100
+ * Ensure the surface client package exists and carries the canonical stable
101
+ * metadata + the authoritative `xema` provenance block. Preserves any
102
+ * pre-existing `dependencies`/`peerDependencies` on an existing client.
103
+ *
104
+ * Returns the absolute client `src` directory (orval's output target).
105
+ */
106
+ export function ensureClientPackage(
107
+ ctx: ResolvedServiceContext,
108
+ surface: ApiSurface,
109
+ ): { clientDir: string; clientSrc: string } {
110
+ const clientDir = surfaceClientDir(ctx, surface);
111
+ const clientSrc = join(clientDir, 'src');
112
+ mkdirSync(clientSrc, { recursive: true });
113
+
114
+ const packageName = surfaceClientPackageName(ctx.api.serviceId, surface);
115
+ const xema: GeneratedClientXemaBlock = {
116
+ kind: 'api-client',
117
+ surface,
118
+ service: ctx.api.serviceId,
119
+ biome: ctx.api.biomeId,
120
+ target: 'server',
121
+ generator: generatorTag(),
122
+ source: `openapi.${surface}.json`,
123
+ };
124
+
125
+ const pkgPath = join(clientDir, 'package.json');
126
+ const existing = existsSync(pkgPath)
127
+ ? (JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>)
128
+ : {};
129
+ const existingScripts = recordOrEmpty(existing.scripts);
130
+ const existingDevDeps = recordOrEmpty(existing.devDependencies);
131
+ const existingPublishConfig = recordOrEmpty(existing.publishConfig);
132
+ // Start from the existing (hand-maintained) package and override ONLY the
133
+ // fields the generator is explicitly responsible for. Every other field
134
+ // authored by hand — `dependencies` (e.g. zod), `peerDependencies`, extra
135
+ // scripts (`clean`/`typecheck`), extra devDependencies (`@types/node`),
136
+ // license/repository/exports/etc. — is preserved verbatim. Regeneration is
137
+ // metadata-stable: it never silently drops hand-maintained config and, per
138
+ // {@link resolveMonotonicVersion}, never downgrades the package version.
139
+ const pkg: Record<string, unknown> = {
140
+ ...existing,
141
+ name: packageName,
142
+ version: resolveMonotonicVersion(existing.version, ctx.version),
143
+ main: './dist/index.js',
144
+ types: './dist/index.d.ts',
145
+ files: ['dist'],
146
+ scripts: {
147
+ ...existingScripts,
148
+ // Generator-owned: the client must build and build-before-publish.
149
+ build: 'tsc -p tsconfig.json',
150
+ prepublishOnly: 'npm run build',
151
+ },
152
+ // This block used to be replaced WHOLESALE, which silently dropped
153
+ // `access: "public"` from every client on every regeneration — contradicting
154
+ // the promise three lines above. npm refuses to publish a SCOPED package
155
+ // without it, so each regeneration quietly armed a publish failure, and the
156
+ // fix (re-adding `access`) had to be re-applied by hand every time.
157
+ //
158
+ // `access` defaults to `public` because every generated @xemahq client is a
159
+ // public package; a hand-authored override still wins. `registry` stays
160
+ // generator-owned.
161
+ publishConfig: {
162
+ access: 'public',
163
+ ...existingPublishConfig,
164
+ registry: 'https://registry.npmjs.org/',
165
+ },
166
+ devDependencies: {
167
+ ...existingDevDeps,
168
+ // Generator-owned: tsc is required to build the generated client.
169
+ typescript: 'catalog:',
170
+ },
171
+ // Generator-owned: the authoritative provenance block is always rewritten.
172
+ xema,
173
+ };
174
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
175
+
176
+ const tsconfigPath = join(clientDir, 'tsconfig.json');
177
+ if (!existsSync(tsconfigPath)) {
178
+ writeFileSync(
179
+ tsconfigPath,
180
+ `${JSON.stringify(CLIENT_TSCONFIG, null, 2)}\n`,
181
+ 'utf8',
182
+ );
183
+ }
184
+
185
+ ensureCustomFetch(ctx, clientSrc);
186
+
187
+ return { clientDir, clientSrc };
188
+ }
189
+
190
+ /**
191
+ * custom-fetch.ts is hand-maintained runtime config, identical across all
192
+ * clients. For an existing client it is left untouched. For a freshly
193
+ * scaffolded surface client we copy the service's public-surface sibling's
194
+ * custom-fetch when present (the most authoritative local copy), else write
195
+ * the canonical embedded template.
196
+ */
197
+ function ensureCustomFetch(
198
+ ctx: ResolvedServiceContext,
199
+ clientSrc: string,
200
+ ): void {
201
+ const target = join(clientSrc, 'custom-fetch.ts');
202
+ if (existsSync(target)) {
203
+ return;
204
+ }
205
+ const publicSibling = join(
206
+ surfaceClientDir(ctx, ApiSurface.Public),
207
+ 'src',
208
+ 'custom-fetch.ts',
209
+ );
210
+ if (existsSync(publicSibling)) {
211
+ cpSync(publicSibling, target);
212
+ return;
213
+ }
214
+ writeFileSync(target, CANONICAL_CUSTOM_FETCH, 'utf8');
215
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { ApiSurface } from '@xemahq/xema-decorators';
5
+
6
+ /**
7
+ * Generated API clients carry a README emitted from the canonical package-README
8
+ * template (see the `write-package-readme` skill). It is fully generator-owned —
9
+ * never hand-edit a generated client's README; change this emitter and
10
+ * regenerate. The content is derived ONLY from the client's own identity (name,
11
+ * the owning service's title, the repo license) so it never needs updating when
12
+ * other packages change.
13
+ */
14
+
15
+ /** Teal accent — the banner taxonomy colour for a generated API client. */
16
+ const CLIENT_ACCENT = '#14B8A6';
17
+
18
+ function detectRepoSpdx(repoRoot: string): string {
19
+ const licensePath = join(repoRoot, 'LICENSE');
20
+ if (existsSync(licensePath)) {
21
+ const head = readFileSync(licensePath, 'utf8').slice(0, 200);
22
+ if (/Apache License/i.test(head)) {
23
+ return 'Apache-2.0';
24
+ }
25
+ if (/all rights reserved/i.test(head)) {
26
+ return 'Proprietary';
27
+ }
28
+ }
29
+ return 'Proprietary';
30
+ }
31
+
32
+ function banner(packageName: string, tagline: string): string {
33
+ return [
34
+ `<svg width="680" height="120" viewBox="0 0 680 120" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${packageName}">`,
35
+ ` <rect width="680" height="120" rx="14" fill="#0B1020"/>`,
36
+ ` <g transform="translate(28,34)">`,
37
+ ` <path d="M26 0 L52 15 L52 45 L26 60 L0 45 L0 15 Z" fill="${CLIENT_ACCENT}" opacity="0.18"/>`,
38
+ ` <path d="M26 12 L41 21 L41 39 L26 48 L11 39 L11 21 Z" fill="${CLIENT_ACCENT}"/>`,
39
+ ` </g>`,
40
+ ` <text x="92" y="52" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="22" fill="#F8FAFC" font-weight="700">${packageName}</text>`,
41
+ ` <text x="92" y="80" font-family="ui-sans-serif,system-ui,sans-serif" font-size="15" fill="#94A3B8">${tagline}</text>`,
42
+ ` <text x="652" y="105" text-anchor="end" font-family="ui-sans-serif,system-ui,sans-serif" font-size="12" fill="#475569">xema.dev</text>`,
43
+ `</svg>`,
44
+ ].join('\n');
45
+ }
46
+
47
+ /**
48
+ * Write `README.md` for a generated client package. Returns nothing; always
49
+ * overwrites (the README is generator-owned).
50
+ */
51
+ export function ensureClientReadme(params: {
52
+ clientDir: string;
53
+ packageName: string;
54
+ serviceTitle: string;
55
+ surface: ApiSurface;
56
+ repoRoot: string;
57
+ }): void {
58
+ const { clientDir, packageName, serviceTitle, surface, repoRoot } = params;
59
+ const enc = encodeURIComponent(packageName);
60
+ const spdx = detectRepoSpdx(repoRoot);
61
+ const surfaceWord = surface === ApiSurface.Public ? '' : `${surface} `;
62
+ const tagline = `Typed, generated ${surfaceWord}HTTP client for the ${serviceTitle}.`;
63
+ const licenseLine =
64
+ spdx === 'Apache-2.0'
65
+ ? 'Apache-2.0 &copy; Xema — [xema.dev](https://xema.dev)'
66
+ : 'Proprietary — &copy; Xema. All rights reserved. — [xema.dev](https://xema.dev)';
67
+
68
+ const md = `<!-- Generated by @xemahq/api-client-generator. Do not edit by hand. -->
69
+ <p align="center">
70
+ ${banner(packageName, tagline)}
71
+ </p>
72
+
73
+ <p align="center">
74
+ <a href="https://xema.dev">Website</a> &middot;
75
+ <a href="https://www.npmjs.com/package/${packageName}">npm</a>
76
+ </p>
77
+
78
+ <p align="center">
79
+ <img alt="npm" src="https://img.shields.io/npm/v/${enc}?color=2563eb&label=npm">
80
+ <img alt="license" src="https://img.shields.io/npm/l/${enc}?color=10b981">
81
+ <img alt="types" src="https://img.shields.io/npm/types/${enc}?color=3178c6">
82
+ </p>
83
+
84
+ # ${packageName}
85
+
86
+ > ${tagline}
87
+
88
+ ## Overview
89
+
90
+ Auto-generated TypeScript client for the **${serviceTitle}**. It exports typed
91
+ request functions and response models that mirror the service's OpenAPI surface,
92
+ so callers get end-to-end type safety without hand-writing HTTP calls. This
93
+ package is produced by \`@xemahq/api-client-generator\` and regenerated whenever
94
+ the service's API changes — do not edit it by hand.
95
+
96
+ ## When to use it
97
+
98
+ - Use it from any TypeScript service or app that calls the ${serviceTitle} over HTTP.
99
+ - You get compile-time types for every endpoint and payload; regenerate to pick
100
+ up API changes rather than editing the client.
101
+
102
+ ## Installation
103
+
104
+ \`\`\`bash
105
+ pnpm add ${packageName}
106
+ \`\`\`
107
+
108
+ ## Usage
109
+
110
+ \`\`\`ts
111
+ // Every endpoint function and response model is exported from the package root.
112
+ import * as client from '${packageName}';
113
+ \`\`\`
114
+
115
+ Call the typed endpoint functions; request and response shapes are fully typed
116
+ from the service's OpenAPI spec.
117
+
118
+ ## License
119
+
120
+ ${licenseLine}
121
+ `;
122
+
123
+ writeFileSync(join(clientDir, 'README.md'), md, 'utf8');
124
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Canonical `custom-fetch.ts` body, identical to the hand-maintained mutator
3
+ * shipped in every existing client package. Used ONLY as the fallback when a
4
+ * freshly scaffolded surface client has no public-surface sibling to copy from
5
+ * (a pure-internal/operator service). Kept byte-identical to the committed
6
+ * version so all clients share one fetch implementation.
7
+ */
8
+ export const CANONICAL_CUSTOM_FETCH = `/**
9
+ * Configurable fetch wrapper for Orval-generated clients.
10
+ *
11
+ * Consumers must call \`configureClient()\` before using any endpoint function.
12
+ * The baseUrl is prepended to the relative paths generated by Orval.
13
+ */
14
+
15
+ export interface ClientConfig {
16
+ /** Static base URL (e.g. 'http://localhost:3140') — no trailing slash. */
17
+ baseUrl?: string;
18
+ /** Per-request base-URL resolver (service-registry-safe). */
19
+ baseUrlResolver?: () => string | Promise<string>;
20
+ /** Optional async callback to get an auth token. */
21
+ getAuthToken?: () => Promise<string>;
22
+ /** Optional callback returning headers to inject on every request. */
23
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
24
+ /** Optional callback invoked on 401 before a single retry. */
25
+ onUnauthorized?: () => Promise<void>;
26
+ /** Maximum retry attempts for transient failures (default: 3). */
27
+ maxRetries?: number;
28
+ }
29
+
30
+ export class ClientError extends Error {
31
+ constructor(
32
+ public readonly status: number,
33
+ public readonly url: string,
34
+ public readonly body: unknown,
35
+ ) {
36
+ super(\`HTTP \${status} from \${url}\`);
37
+ this.name = 'ClientError';
38
+ }
39
+ }
40
+
41
+ let clientConfig: ClientConfig | null = null;
42
+
43
+ export function configureClient(config: ClientConfig): void {
44
+ clientConfig = config;
45
+ }
46
+
47
+ export function getClientConfig(): ClientConfig {
48
+ if (!clientConfig) {
49
+ throw new Error(
50
+ 'Client not configured. Call configureClient({ baseUrl }) before using endpoint functions.',
51
+ );
52
+ }
53
+ return clientConfig;
54
+ }
55
+
56
+ const RETRYABLE_STATUSES = [429, 502, 503, 504];
57
+
58
+ async function buildHeaders(
59
+ config: ClientConfig,
60
+ callerHeaders: RequestInit['headers'],
61
+ ): Promise<Headers> {
62
+ const headers = new Headers(callerHeaders);
63
+ if (config.getHeaders) {
64
+ const globalHeaders = await Promise.resolve(config.getHeaders());
65
+ for (const [key, value] of Object.entries(globalHeaders)) {
66
+ if (!headers.has(key)) {
67
+ headers.set(key, value);
68
+ }
69
+ }
70
+ }
71
+ if (config.getAuthToken && !headers.has('Authorization')) {
72
+ const token = await config.getAuthToken();
73
+ headers.set('Authorization', \`Bearer \${token}\`);
74
+ }
75
+ return headers;
76
+ }
77
+
78
+ export const customFetch = async <T>(
79
+ url: string,
80
+ options: RequestInit,
81
+ ): Promise<T> => {
82
+ const config = getClientConfig();
83
+ const base = config.baseUrlResolver
84
+ ? await config.baseUrlResolver()
85
+ : config.baseUrl;
86
+ if (base === undefined) {
87
+ throw new Error(
88
+ 'Client not configured: set baseUrl or baseUrlResolver via configureClient().',
89
+ );
90
+ }
91
+ const fullUrl = \`\${base}\${url}\`;
92
+ const maxRetries = config.maxRetries ?? 3;
93
+
94
+ const headers = await buildHeaders(config, options.headers);
95
+ const requestInit: RequestInit = { ...options, headers };
96
+
97
+ let delay = 1000;
98
+ let lastError: Error | undefined;
99
+
100
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
101
+ try {
102
+ const response = await fetch(fullUrl, requestInit);
103
+
104
+ if (response.status === 401 && config.onUnauthorized && attempt === 0) {
105
+ await config.onUnauthorized();
106
+ const refreshedHeaders = await buildHeaders(config, options.headers);
107
+ const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });
108
+ const retryBody = await parseBody(retryResponse);
109
+ if (retryResponse.status >= 400) {
110
+ throw new ClientError(retryResponse.status, fullUrl, retryBody);
111
+ }
112
+ return unwrapEnvelope(retryBody) as T;
113
+ }
114
+
115
+ if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {
116
+ const body = await parseBody(response);
117
+ if (response.status >= 400) {
118
+ throw new ClientError(response.status, fullUrl, body);
119
+ }
120
+ return unwrapEnvelope(body) as T;
121
+ }
122
+
123
+ const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
124
+ const waitMs = retryAfter ?? addJitter(delay);
125
+ await sleep(waitMs);
126
+ delay = Math.min(delay * 2, 30_000);
127
+ } catch (error) {
128
+ if (error instanceof ClientError) throw error;
129
+ lastError = error instanceof Error ? error : new Error(String(error));
130
+ if (attempt >= maxRetries) throw lastError;
131
+ const waitMs = addJitter(delay);
132
+ await sleep(waitMs);
133
+ delay = Math.min(delay * 2, 30_000);
134
+ }
135
+ }
136
+
137
+ throw lastError ?? new Error(\`All retries exhausted for \${fullUrl}\`);
138
+ };
139
+
140
+ /**
141
+ * Xema platform services wrap every 2xx payload in a { data: T } envelope via
142
+ * the global ResponseEnvelopeInterceptor (platform-common). Generated types
143
+ * describe the INNER T (controllers declare their return type as T, not
144
+ * { data: T }), so the client must peel data to keep the static types and the
145
+ * wire shape consistent — otherwise consumers read fields off the envelope and
146
+ * get undefined.
147
+ *
148
+ * Only 2xx bodies reach this helper: non-2xx responses are surfaced as a thrown
149
+ * ClientError before unwrap, and error envelopes are emitted by the
150
+ * GlobalExceptionFilter, never wrapped in { data }. Paginated lists arrive as
151
+ * { data: T[], pagination: {...} }; consumers read both fields, so that envelope
152
+ * is kept intact (detected by the presence of pagination). Any body without a
153
+ * top-level data field is returned as-is.
154
+ */
155
+ function unwrapEnvelope(body: unknown): unknown {
156
+ if (
157
+ body === null ||
158
+ typeof body !== 'object' ||
159
+ Array.isArray(body) ||
160
+ !('data' in body)
161
+ ) {
162
+ return body;
163
+ }
164
+ const envelope = body as { data: unknown; pagination?: unknown };
165
+ if ('pagination' in envelope) {
166
+ return envelope;
167
+ }
168
+ return envelope.data;
169
+ }
170
+
171
+ async function parseBody(response: Response): Promise<unknown> {
172
+ const contentType = response.headers.get('content-type');
173
+ if (contentType?.includes('application/json')) {
174
+ return response.json();
175
+ }
176
+ if (response.status === 204) {
177
+ return undefined;
178
+ }
179
+ return response.text();
180
+ }
181
+
182
+ function parseRetryAfter(value: string | null): number | undefined {
183
+ if (!value) return undefined;
184
+ const seconds = Number(value);
185
+ if (!isNaN(seconds) && seconds >= 0) return seconds * 1000;
186
+ const date = new Date(value);
187
+ if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());
188
+ return undefined;
189
+ }
190
+
191
+ function addJitter(delay: number): number {
192
+ return delay + (Math.random() * 2 - 1) * delay * 0.25;
193
+ }
194
+
195
+ function sleep(ms: number): Promise<void> {
196
+ return new Promise(resolve => setTimeout(resolve, ms));
197
+ }
198
+
199
+ export default customFetch;
200
+ `;