liaise 0.0.0 → 5.0.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 (55) hide show
  1. package/CHANGELOG.md +961 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +925 -0
  4. package/README.md +1392 -4
  5. package/dist/built-in-middleware.d.ts +232 -0
  6. package/dist/built-in-middleware.js +127 -0
  7. package/dist/create-api.d.ts +120 -0
  8. package/dist/create-api.js +370 -0
  9. package/dist/define-request.d.ts +251 -0
  10. package/dist/define-request.js +4 -0
  11. package/dist/graphql.d.ts +30 -0
  12. package/dist/graphql.js +272 -0
  13. package/dist/index.d.ts +16 -0
  14. package/dist/index.js +6 -0
  15. package/dist/middleware.d.ts +77 -0
  16. package/dist/middleware.js +12 -0
  17. package/dist/paginate.d.ts +71 -0
  18. package/dist/paginate.js +15 -0
  19. package/dist/request.d.ts +136 -0
  20. package/dist/request.js +12 -0
  21. package/dist/result.d.ts +178 -0
  22. package/dist/result.js +20 -0
  23. package/dist/testing.d.ts +51 -0
  24. package/dist/testing.js +135 -0
  25. package/dist/types.d.ts +751 -0
  26. package/dist/types.js +1 -0
  27. package/dist/utils/abort-kind.d.ts +58 -0
  28. package/dist/utils/abort-kind.js +28 -0
  29. package/dist/utils/any-signal.d.ts +18 -0
  30. package/dist/utils/any-signal.js +29 -0
  31. package/dist/utils/backstop.d.ts +49 -0
  32. package/dist/utils/backstop.js +80 -0
  33. package/dist/utils/budget.d.ts +53 -0
  34. package/dist/utils/budget.js +20 -0
  35. package/dist/utils/cache.d.ts +54 -0
  36. package/dist/utils/cache.js +37 -0
  37. package/dist/utils/dedupe.d.ts +90 -0
  38. package/dist/utils/dedupe.js +20 -0
  39. package/dist/utils/headers.d.ts +1 -0
  40. package/dist/utils/headers.js +19 -0
  41. package/dist/utils/path-params.d.ts +89 -0
  42. package/dist/utils/path-params.js +80 -0
  43. package/dist/utils/serialize.d.ts +48 -0
  44. package/dist/utils/serialize.js +21 -0
  45. package/dist/utils/share.d.ts +49 -0
  46. package/dist/utils/share.js +48 -0
  47. package/dist/utils/special-body.d.ts +18 -0
  48. package/dist/utils/special-body.js +7 -0
  49. package/dist/utils/stable-key.d.ts +55 -0
  50. package/dist/utils/stable-key.js +111 -0
  51. package/dist/utils/timeout.d.ts +27 -0
  52. package/dist/utils/timeout.js +7 -0
  53. package/dist/utils/validate.d.ts +32 -0
  54. package/dist/utils/validate.js +6 -0
  55. package/package.json +67 -5
@@ -0,0 +1,370 @@
1
+ import { ApiError, createSuccessResult, createErrorResult, createNetworkErrorResult } from './result.js';
2
+ import { composeMiddleware } from './middleware.js';
3
+ import { buildUrl, joinUrl, FragmentError } from './utils/path-params.js';
4
+ import { serializeBody } from './utils/serialize.js';
5
+ import { DedupeTracker } from './utils/dedupe.js';
6
+ import { ShareTracker, isAbandoned } from './utils/share.js';
7
+ import { mergeHeaders } from './utils/headers.js';
8
+ import { abortKind, propagatesReason } from './utils/abort-kind.js';
9
+ import { anySignal } from './utils/any-signal.js';
10
+ import { operationBudget, perCallerBudget } from './utils/budget.js';
11
+ import { stableKey } from './utils/stable-key.js';
12
+ import { createBackstop } from './utils/backstop.js';
13
+ import { isSpecialBody } from './utils/special-body.js';
14
+ import { runSchema } from './utils/validate.js';
15
+ const EMPTY_JSON_BODY = Symbol('liaise.emptyJsonBody');
16
+ async function parseResponse(response, responseType = 'json') {
17
+ switch (responseType) {
18
+ case 'text':
19
+ return response.text();
20
+ case 'blob':
21
+ return response.blob();
22
+ case 'arrayBuffer':
23
+ return response.arrayBuffer();
24
+ case 'formData':
25
+ return response.formData();
26
+ case 'none':
27
+ try {
28
+ await response.body?.cancel();
29
+ }
30
+ catch {
31
+ }
32
+ return undefined;
33
+ case 'json':
34
+ default: {
35
+ const text = await response.text();
36
+ return text ? JSON.parse(text) : EMPTY_JSON_BODY;
37
+ }
38
+ }
39
+ }
40
+ function resolveRequestUrl(baseUrl, request, params) {
41
+ const asQuery = request.shouldSerializeAsQuery;
42
+ const paramsIsSpecialBody = isSpecialBody(params);
43
+ const { url, remaining } = buildUrl(baseUrl, request.config.path, paramsIsSpecialBody ? {} : params, asQuery);
44
+ return { url, remaining, asQuery, paramsIsSpecialBody };
45
+ }
46
+ function urlForError(baseUrl, request, params) {
47
+ try {
48
+ return resolveRequestUrl(baseUrl, request, params).url;
49
+ }
50
+ catch (err) {
51
+ if (err instanceof FragmentError)
52
+ return err.resolvedUrl;
53
+ return joinUrl(baseUrl, request.config.path);
54
+ }
55
+ }
56
+ function syntheticResult(reason, signal, fallbackKind, method, url, params, retry) {
57
+ const isOurCancellation = signal?.aborted === true &&
58
+ (fallbackKind !== 'middleware' || propagatesReason(reason, signal.reason));
59
+ const kind = isOurCancellation ? (abortKind(signal.reason) ?? 'abort') : fallbackKind;
60
+ const error = new ApiError({
61
+ kind,
62
+ status: 0,
63
+ statusText: '',
64
+ body: reason,
65
+ headers: new Headers(),
66
+ request: { method, url, params }
67
+ });
68
+ return createNetworkErrorResult(error, retry);
69
+ }
70
+ function isEmptyHeaders(init) {
71
+ if (init === undefined)
72
+ return true;
73
+ if (init instanceof Headers) {
74
+ let empty = true;
75
+ init.forEach(() => { empty = false; });
76
+ return empty;
77
+ }
78
+ if (Array.isArray(init))
79
+ return init.length === 0;
80
+ return Object.keys(init).length === 0;
81
+ }
82
+ export function createApi(config) {
83
+ const { baseUrl, requests, middleware: globalMiddleware = [], headers: globalHeaders, onError } = config;
84
+ const fireOnError = (error) => {
85
+ if (error.kind === 'abort')
86
+ return;
87
+ if (!onError)
88
+ return;
89
+ try {
90
+ onError(error);
91
+ }
92
+ catch {
93
+ }
94
+ };
95
+ const dedupeTracker = new DedupeTracker();
96
+ const shareTracker = new ShareTracker();
97
+ for (const [name, request] of Object.entries(requests)) {
98
+ if (request.config.share && request.config.dedupe) {
99
+ throw new Error(`Request "${name}" sets both share and dedupe. They are opposites — ` +
100
+ `dedupe cancels the previous call, share joins it. Pick one.`);
101
+ }
102
+ }
103
+ const api = {};
104
+ for (const [name, request] of Object.entries(requests)) {
105
+ api[name] = (params = {}, options = {}) => {
106
+ function buildFailedResult(reason, signal, fallbackKind, url) {
107
+ return syntheticResult(reason, signal, fallbackKind, request.config.method, url ?? urlForError(baseUrl, request, params), params, retry);
108
+ }
109
+ function failedResult(reason, signal, fallbackKind) {
110
+ const result = buildFailedResult(reason, signal, fallbackKind);
111
+ fireOnError(result.error);
112
+ return result;
113
+ }
114
+ const execute = (sharedSignal, onSettled) => {
115
+ try {
116
+ const allMiddleware = [
117
+ ...globalMiddleware,
118
+ ...(request.config.middleware ?? []),
119
+ ...(options.middleware ?? [])
120
+ ];
121
+ const operation = operationBudget(options.timeout, request.config.timeout, options.signal, sharedSignal !== undefined);
122
+ const callerSignal = sharedSignal
123
+ ? anySignal([sharedSignal, operation])
124
+ : operation;
125
+ let dedupeController;
126
+ const core = async (ctx) => {
127
+ try {
128
+ if (sharedSignal && ctx.request.signal !== sharedSignal) {
129
+ ctx.request.signal = anySignal([ctx.request.signal, sharedSignal]);
130
+ }
131
+ if (request.config.dedupe && !dedupeController) {
132
+ const tracked = dedupeTracker.track(name, ctx.request.signal ?? callerSignal);
133
+ dedupeController = tracked.controller;
134
+ ctx.request.signal = tracked.signal;
135
+ backstop.watch(tracked.controller.signal);
136
+ }
137
+ const fetchInit = {
138
+ method: ctx.request.method,
139
+ headers: ctx.request.headers,
140
+ signal: ctx.request.signal
141
+ };
142
+ if (ctx.request.body !== null && ctx.request.body !== undefined) {
143
+ fetchInit.body = ctx.request.body;
144
+ }
145
+ const response = await fetch(ctx.request.url, fetchInit);
146
+ if (!response.ok) {
147
+ let body;
148
+ try {
149
+ const errorResponseType = request.config.responseType === 'none' ? 'json' : request.config.responseType;
150
+ body = await parseResponse(response, errorResponseType);
151
+ if (body === EMPTY_JSON_BODY)
152
+ body = null;
153
+ }
154
+ catch (parseErr) {
155
+ const signal = ctx.request.signal;
156
+ if (signal?.aborted === true) {
157
+ const error = new ApiError({
158
+ status: 0,
159
+ kind: abortKind(signal.reason) ?? 'abort',
160
+ statusText: '',
161
+ body: parseErr,
162
+ headers: new Headers(),
163
+ request: { method: ctx.request.method, url: ctx.request.url, params }
164
+ });
165
+ return createNetworkErrorResult(error, retry);
166
+ }
167
+ body = null;
168
+ }
169
+ const error = new ApiError({
170
+ status: response.status,
171
+ kind: 'http',
172
+ statusText: response.statusText,
173
+ body,
174
+ headers: response.headers,
175
+ request: { method: ctx.request.method, url: ctx.request.url, params }
176
+ });
177
+ return createErrorResult(error, response, retry);
178
+ }
179
+ let data;
180
+ try {
181
+ data = await parseResponse(response, request.config.responseType);
182
+ }
183
+ catch (parseErr) {
184
+ const signal = ctx.request.signal;
185
+ if (signal?.aborted === true) {
186
+ const error = new ApiError({
187
+ status: 0,
188
+ kind: abortKind(signal.reason) ?? 'abort',
189
+ statusText: '',
190
+ body: parseErr,
191
+ headers: new Headers(),
192
+ request: { method: ctx.request.method, url: ctx.request.url, params }
193
+ });
194
+ return createNetworkErrorResult(error, retry);
195
+ }
196
+ const error = new ApiError({
197
+ kind: 'parse',
198
+ status: response.status,
199
+ statusText: response.statusText,
200
+ body: parseErr,
201
+ headers: response.headers,
202
+ request: { method: ctx.request.method, url: ctx.request.url, params }
203
+ });
204
+ return createErrorResult(error, response, retry);
205
+ }
206
+ if (data === EMPTY_JSON_BODY) {
207
+ const error = new ApiError({
208
+ kind: 'parse',
209
+ status: response.status,
210
+ statusText: response.statusText,
211
+ body: '',
212
+ headers: response.headers,
213
+ request: { method: ctx.request.method, url: ctx.request.url, params }
214
+ });
215
+ return createErrorResult(error, response, retry);
216
+ }
217
+ if (request.config.schema) {
218
+ let outcome;
219
+ try {
220
+ outcome = await runSchema(request.config.schema, data);
221
+ }
222
+ catch (validatorErr) {
223
+ const error = new ApiError({
224
+ kind: 'parse',
225
+ status: response.status,
226
+ statusText: response.statusText,
227
+ body: validatorErr,
228
+ headers: response.headers,
229
+ request: { method: ctx.request.method, url: ctx.request.url, params }
230
+ });
231
+ return createErrorResult(error, response, retry);
232
+ }
233
+ if (!outcome.ok) {
234
+ const error = new ApiError({
235
+ kind: 'parse',
236
+ status: response.status,
237
+ statusText: response.statusText,
238
+ body: outcome.issues,
239
+ headers: response.headers,
240
+ request: { method: ctx.request.method, url: ctx.request.url, params }
241
+ });
242
+ return createErrorResult(error, response, retry);
243
+ }
244
+ data = outcome.value;
245
+ }
246
+ return createSuccessResult(data, response, retry);
247
+ }
248
+ catch (err) {
249
+ const signal = ctx.request.signal;
250
+ const kind = signal?.aborted === true
251
+ ? (abortKind(signal.reason) ?? 'abort')
252
+ : (abortKind(err) ?? 'network');
253
+ const error = new ApiError({
254
+ status: 0,
255
+ kind,
256
+ statusText: '',
257
+ body: err,
258
+ headers: new Headers(),
259
+ request: { method: ctx.request.method, url: ctx.request.url, params }
260
+ });
261
+ return createNetworkErrorResult(error, retry);
262
+ }
263
+ };
264
+ const { url, remaining, asQuery, paramsIsSpecialBody } = resolveRequestUrl(baseUrl, request, params);
265
+ const headers = mergeHeaders(globalHeaders, request.config.headers, options.headers);
266
+ let body = null;
267
+ if (!asQuery) {
268
+ const toSerialize = paramsIsSpecialBody ? params : remaining;
269
+ if (paramsIsSpecialBody || Object.keys(remaining).length > 0) {
270
+ const serialized = serializeBody(toSerialize);
271
+ body = serialized.body;
272
+ if (serialized.contentType && !headers.has('Content-Type')) {
273
+ headers.set('Content-Type', serialized.contentType);
274
+ }
275
+ }
276
+ }
277
+ const context = {
278
+ request: {
279
+ method: request.config.method,
280
+ url,
281
+ path: request.config.path,
282
+ params,
283
+ headers,
284
+ body,
285
+ signal: callerSignal
286
+ },
287
+ requestName: name
288
+ };
289
+ const backstop = createBackstop(signal => buildFailedResult(signal.reason, signal, 'abort', context.request.url));
290
+ backstop.watch(callerSignal);
291
+ const composed = composeMiddleware(allMiddleware, backstop.guard(core), options.skipMiddleware ?? []);
292
+ let resultPromise;
293
+ try {
294
+ resultPromise = composed(context).catch((err) => buildFailedResult(err, context.request.signal, 'middleware', context.request.url));
295
+ }
296
+ catch (err) {
297
+ resultPromise = Promise.resolve(buildFailedResult(err, context.request.signal, 'middleware', context.request.url));
298
+ }
299
+ return backstop.follow(resultPromise, (result, preempted) => {
300
+ if (typeof onSettled === 'function') {
301
+ try {
302
+ onSettled();
303
+ }
304
+ catch { }
305
+ }
306
+ if (request.config.dedupe && dedupeController) {
307
+ if (preempted)
308
+ dedupeController.abort();
309
+ dedupeTracker.clear(name, dedupeController);
310
+ }
311
+ const abandoned = sharedSignal?.aborted === true && isAbandoned(sharedSignal.reason);
312
+ if (result.error && !abandoned)
313
+ fireOnError(result.error);
314
+ return result;
315
+ }, (err) => failedResult(err, undefined, 'abort'));
316
+ }
317
+ catch (err) {
318
+ return Promise.resolve(failedResult(err, undefined, 'network'));
319
+ }
320
+ };
321
+ function retry() {
322
+ return execute();
323
+ }
324
+ try {
325
+ const key = request.config.share === true &&
326
+ isEmptyHeaders(options.headers) &&
327
+ (options.middleware === undefined || options.middleware.length === 0)
328
+ ? stableKey(params)
329
+ : null;
330
+ if (key === null)
331
+ return execute();
332
+ const shareKey = `${name}|${key}`;
333
+ const perCaller = perCallerBudget(options.timeout, options.signal);
334
+ const { promise, release, hasSettled } = shareTracker.acquire(shareKey, (signal, markSettled) => execute(signal, markSettled));
335
+ if (!perCaller)
336
+ return promise.then(r => r, (err) => failedResult(err, undefined, 'network'));
337
+ return new Promise(resolve => {
338
+ let done = false;
339
+ const finish = (r) => {
340
+ if (done)
341
+ return;
342
+ done = true;
343
+ perCaller.removeEventListener('abort', onAbort);
344
+ resolve(r);
345
+ };
346
+ promise.then(r => finish(r), (err) => {
347
+ if (done)
348
+ return;
349
+ finish(failedResult(err, undefined, 'network'));
350
+ });
351
+ const onAbort = () => {
352
+ release();
353
+ const result = buildFailedResult(perCaller.reason, perCaller, 'abort');
354
+ if (!hasSettled())
355
+ fireOnError(result.error);
356
+ finish(result);
357
+ };
358
+ if (perCaller.aborted)
359
+ onAbort();
360
+ else
361
+ perCaller.addEventListener('abort', onAbort, { once: true });
362
+ });
363
+ }
364
+ catch (err) {
365
+ return Promise.resolve(failedResult(err, undefined, 'network'));
366
+ }
367
+ };
368
+ }
369
+ return api;
370
+ }
@@ -0,0 +1,251 @@
1
+ import { Request } from './request.js';
2
+ import type { RequestConfig, ResponseType, StandardSchemaV1, InferOutput } from './types.js';
3
+ /**
4
+ * The characters a path token name may contain.
5
+ *
6
+ * This list is not arbitrary and must not be "simplified": it mirrors
7
+ * `buildUrl`'s substitution pattern, `:${key}(?=[^a-zA-Z0-9_]|$)`
8
+ * (src/utils/path-params.ts). If the two ever disagree, the types describe a
9
+ * URL the runtime does not build.
10
+ */
11
+ type WordChar = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '_';
12
+ /**
13
+ * Consumes the leading word characters of `S` — a token name — and stops at the
14
+ * first character that is not one, exactly where `buildUrl`'s lookahead stops.
15
+ *
16
+ * Character-at-a-time rather than splitting on '/', because a token does not
17
+ * have to end at a slash: `/users/:id.json` substitutes `:id` and leaves
18
+ * `.json`, and `/search/:q?x=1` substitutes `:q`.
19
+ */
20
+ type TakeName<S extends string, Acc extends string = ''> = S extends `${infer C}${infer Rest}` ? C extends WordChar ? TakeName<Rest, `${Acc}${C}`> : Acc : Acc;
21
+ /**
22
+ * The params a path template requires, as `{ token: string | number }`.
23
+ *
24
+ * `string | number` because `buildUrl` does `encodeURIComponent(String(value))`
25
+ * — a numeric id is as valid as a string one. An object is rejected, which is
26
+ * right: `String({})` is `"[object Object]"`, a silently wrong URL.
27
+ *
28
+ * A path with no tokens gives `{}`, and `Record<string, never> extends {}`
29
+ * holds, so `ApiMethod`'s optional-params branch still fires and `api.health()`
30
+ * stays callable with no arguments.
31
+ *
32
+ * A token must BEGIN a path segment, mirroring `buildUrl`'s Phase 1b
33
+ * (src/utils/path-params.ts, search "A token must BEGIN a path segment"):
34
+ * matching is anchored to `/:` rather than a bare `:`, so a colon that appears
35
+ * mid-segment — a Google-style custom method like `/v1/documents:batchGet`,
36
+ * or a time like `/events/at/12:30` — is never mistaken for a token. Both of
37
+ * those paths parse to `{}`, not `{ batchGet: ... }` or `{ '30': ... }`.
38
+ *
39
+ * A missing leading slash is normalised before anchoring. `buildUrl` requires a
40
+ * token to sit at index 0 of a `split('/')` segment, which means "preceded by
41
+ * `/`, **or at the very start of the string**" — and segment 0 always starts at
42
+ * index 0. Prepending `/` when it is absent makes this type's `/:` requirement
43
+ * exactly equivalent to that condition, and is a no-op for a path that already
44
+ * has one.
45
+ *
46
+ * 4.1.0 shipped the anchor without the normalisation and so under-inferred a
47
+ * path whose token *starts the string*: `':id'` inferred nothing while the
48
+ * request still demanded `id`, failing at runtime. A path like `'users/:id'`
49
+ * was never affected — it contains a literal `/:`.
50
+ *
51
+ * Exported for `tests/types.test-d.ts`. Not re-exported from `src/index.ts`, so
52
+ * it is not public API — see index.ts's note on what is deliberately withheld.
53
+ */
54
+ type Anchored<P extends string> = P extends `${string}/:${infer Tail}` ? TakeName<Tail> extends infer Name extends string ? Name extends '' ? Anchored<Tail> : {
55
+ [K in Name]: string | number;
56
+ } & (Tail extends `${Name}${infer Rest}` ? Anchored<Rest> : {}) : {} : {};
57
+ export type PathParams<P extends string> = Anchored<P extends `/${string}` ? P : `/${P}`>;
58
+ /**
59
+ * Flattens an intersection into a single object type.
60
+ *
61
+ * Presentational, but load-bearing for the feature's value: without it, hovering
62
+ * an endpoint in an editor shows `PathParams<'/orgs/:org/repos'> & { page?:
63
+ * number }` instead of `{ org: string | number; page?: number }`.
64
+ */
65
+ export type Id<T> = {
66
+ [K in keyof T]: T[K];
67
+ } & {};
68
+ /**
69
+ * Rejects `responseType: 'none'` when `TResponse` is a real type.
70
+ *
71
+ * `'none'` means the endpoint sends no body on success and `data` is
72
+ * `undefined` at runtime. Declaring a response type alongside it is a
73
+ * contradiction that was, until now, only a documented convention.
74
+ *
75
+ * Fires only on the LITERAL `'none'`. A config whose `responseType` is the
76
+ * widened `ResponseType | undefined` — a `RequestConfig`-typed variable, or a
77
+ * spread of one — infers as the whole union, which is not `'none'`, and passes
78
+ * through untouched. That matters: constraining the config parameter directly
79
+ * rejects both of those legitimate forms, which is exactly the permissiveness
80
+ * problem recorded in `ResponseType`'s JSDoc in types.ts.
81
+ *
82
+ * The other side of "literal only" is `TResponse`, not `TRT`: the check is
83
+ * `undefined extends TResponse`, so any `TResponse` that ADMITS `undefined` —
84
+ * `User | undefined`, `any`, `unknown` — satisfies the guard and is not
85
+ * rejected, same as the exact type `undefined`. Only a `TResponse` that
86
+ * excludes `undefined` entirely trips it.
87
+ *
88
+ * The failure branch uses a non-colliding marker property, `__emptyBodyMismatch`,
89
+ * rather than reusing `responseType`. Reusing `responseType` collapses that
90
+ * property's type to `never` via intersection with the existing `responseType?:
91
+ * TRT`, which TypeScript then reports as a per-property mismatch on unrelated
92
+ * fields like `method`, never mentioning the message. `__emptyBodyMismatch`
93
+ * collides with nothing a real config has, so TypeScript instead reports it as
94
+ * a missing required property, and the sentence rides along in that message.
95
+ */
96
+ type EmptyBodyGuard<TRT, TResponse> = [
97
+ TRT
98
+ ] extends ['none'] ? undefined extends TResponse ? unknown : {
99
+ __emptyBodyMismatch: 'declare TResponse as undefined when responseType is none';
100
+ } : unknown;
101
+ /**
102
+ * Rejects a `path` literal containing a URL fragment.
103
+ *
104
+ * `fetch` never transmits a fragment, so one in a request path cannot do
105
+ * anything the caller intended — and before 4.2.1 it silently ate the query
106
+ * string as well. 4.2.1 made `buildUrl` refuse it at runtime, which left the
107
+ * type accepting what the runtime rejects: `defineRequest()({ path:
108
+ * '/docs#section' })` compiled, and then failed on every call. That divergence
109
+ * is the defect this library exists to prevent (§2.4 closed the same gap in the
110
+ * other direction), so the guard closes it at the call site.
111
+ *
112
+ * **Fires only on a literal.** `string extends `${string}#${string}`` is false,
113
+ * so a `RequestConfig`-typed variable, a spread of one, or any path computed at
114
+ * runtime still compiles. That permissiveness is not an oversight — it is the
115
+ * constraint that sank the 3.1.0 guard attempt, and `EmptyBodyGuard` carries it
116
+ * for the same reason. A config assembled at runtime cannot be checked at
117
+ * compile time, and refusing it would make the factory unusable for that shape.
118
+ *
119
+ * `new Request` gets no equivalent: it takes no path literal, so there is
120
+ * nothing for a guard to read. That is the asymmetry §3.4 already accepted, and
121
+ * it is why the factory exists at all.
122
+ *
123
+ * The marker property follows `EmptyBodyGuard`'s reasoning exactly — reusing
124
+ * `path` would intersect to `never` and make TypeScript report a mismatch on
125
+ * unrelated fields, never showing the sentence. `__fragmentInPath` collides
126
+ * with nothing a real config has, so it is reported as a missing required
127
+ * property and the message rides along.
128
+ */
129
+ type FragmentGuard<TPath> = TPath extends `${string}#${string}` ? {
130
+ __fragmentInPath: 'a URL fragment is never sent to the server — remove the # and everything after it';
131
+ } : unknown;
132
+ /**
133
+ * The response type a schema supplies, or `unknown` when it supplies none.
134
+ *
135
+ * "Supplies none" covers two cases that must be treated identically: `TSchema`
136
+ * is literally `undefined` (no `schema` was written), and `TSchema` is present
137
+ * but its OUTPUT is `unknown` or `any` (a widened `StandardSchemaV1<unknown>`,
138
+ * which is what a bare `RequestConfig`-typed variable's `schema` field infers
139
+ * as — see `SchemaConflictGuard`). Both collapse to `unknown` here, which is
140
+ * what lets `SchemaConflictGuard` and `defineRequest`'s return type ask the
141
+ * SAME question ("did a schema meaningfully narrow the response?") and get the
142
+ * same answer. They used to ask it separately and disagreed: the guard read
143
+ * the schema's OUTPUT (`unknown extends InferOutput<TSchema>`) while the
144
+ * return type read `TSchema`'s presence directly (`[TSchema] extends
145
+ * [undefined]`), so a widened config passed the guard (its output read as "not
146
+ * given") but the return type's presence check still saw `TSchema` and took
147
+ * the `InferOutput` branch, producing `unknown` — silently discarding an
148
+ * explicit `TResponse`. One shared rule, asked once.
149
+ */
150
+ type SchemaOut<TSchema> = [TSchema] extends [undefined] ? unknown : InferOutput<TSchema>;
151
+ /**
152
+ * Refuses a schema and an explicit response type together.
153
+ *
154
+ * Two sources of truth that can disagree is a mistake, and this project makes a
155
+ * contradiction unrepresentable rather than picking a winner silently — the same
156
+ * reasoning as the `share` + `dedupe` throw and the empty-body guard.
157
+ *
158
+ * It fires even when the two AGREE, because the failure it prevents is temporal:
159
+ * the schema changes, the explicit type is now wrong, and nothing reports it
160
+ * because it was never being read.
161
+ *
162
+ * `unknown extends TResponse` is true only when no explicit argument was given,
163
+ * which is what makes "was one supplied?" answerable at all. `any` satisfies it
164
+ * too and counts as "not given" — acceptable, since `defineRequest<any>()` is
165
+ * already opting out of everything.
166
+ *
167
+ * The "was a schema given?" side reads `SchemaOut<TSchema>` — the schema's
168
+ * OUTPUT — rather than asking whether `TSchema` itself is `undefined`, and the
169
+ * config field feeding it is `Omit<RequestConfig, 'schema'> & { schema?:
170
+ * TSchema }` rather than `RequestConfig & { schema?: TSchema }`. Both changes
171
+ * exist for the same reason: `RequestConfig` already declares `schema?:
172
+ * StandardSchemaV1<unknown>` (for `new Request`), so a config typed as plain
173
+ * `RequestConfig` — or a spread of one — structurally carries that field
174
+ * whether or not a schema was actually written. Left in the intersection, that
175
+ * field becomes a second, competing inference source for `TSchema`, and —
176
+ * only when `TSchema` also appears inside a conditional in the SAME parameter,
177
+ * which is what a "was one given?" guard requires — TypeScript stops
178
+ * decomposing a non-literal argument (a bare `RequestConfig`-typed variable)
179
+ * property by property and falls back to `TSchema`'s default instead, which
180
+ * then disagrees with the very shape it just failed to read. `Omit` removes
181
+ * the competing source; reading the OUTPUT instead of `TSchema` itself
182
+ * survives whatever that source infers anyway, because an unknown-output
183
+ * schema (the widened `StandardSchemaV1<unknown>`, same as a plain
184
+ * `undefined`) reads as "not given" right alongside the real absence.
185
+ *
186
+ * A schema carrying no type information — an OUTPUT of `unknown` or `any` —
187
+ * is treated as no schema at all: the explicit response type is HONOURED, and
188
+ * no conflict is raised. `defineRequest<User>()({ ..., schema:
189
+ * looselyTypedSchema })` compiles and its `data` is `User`, not `unknown`.
190
+ * This is not a gap being tolerated; it is what makes a widened
191
+ * `RequestConfig`-typed config work at all — a spread of one carries exactly
192
+ * `schema?: StandardSchemaV1<unknown>`, inherited from `RequestConfig` itself,
193
+ * and a schema written directly can be just as loosely typed. A schema that
194
+ * DOES carry type information — a concrete `Output` — supplies the response
195
+ * type instead (see `defineRequest`'s return-type expression), and an
196
+ * explicit type alongside THAT is the conflict this guard exists to catch.
197
+ *
198
+ * This falls out of `SchemaOut` for free, and only BECAUSE the guard and
199
+ * `defineRequest`'s return type read it identically. That took two attempts:
200
+ * moving the guard onto `SchemaOut` (the OUTPUT paragraph above) fixed the
201
+ * guard's own over-rejection of widened configs, but the return type was left
202
+ * reading `[TSchema] extends [undefined]` directly — the schema's PRESENCE,
203
+ * not its output — so a widened config now passed the guard while the return
204
+ * type still discarded its `TResponse`. Same mistake as the original
205
+ * over-rejection, just relocated: two formulas answering one question. Both
206
+ * disappeared once both sites read `SchemaOut`. Do not let them drift apart
207
+ * again: change `SchemaConflictGuard` and `defineRequest`'s return-type
208
+ * expression together, and reverify against `tests/types.test-d.ts`'s
209
+ * widened-config assertions, which check the resulting `data` type via
210
+ * `createApi` rather than merely that the call compiles — editing one site
211
+ * without the other, unverified, is exactly how this regressed.
212
+ */
213
+ type SchemaConflictGuard<TSchema, TResponse> = unknown extends SchemaOut<TSchema> ? unknown : unknown extends TResponse ? unknown : {
214
+ __schemaSuppliesTheType: 'omit the explicit response type when passing a schema';
215
+ };
216
+ /**
217
+ * Declares an endpoint, inferring its path parameters from the `path` literal.
218
+ *
219
+ * Curried because TypeScript has no partial type-argument inference: supplying
220
+ * `TResponse` and the config in ONE call makes `TPath` fall back to its
221
+ * constraint, `PathParams<string>` resolve to `{}`, and `{}` accepts anything —
222
+ * the feature disappears with no error anywhere, which is precisely how the
223
+ * 3.1.0 guard attempt failed. Two calls keep the response type explicit and the
224
+ * path inferred.
225
+ *
226
+ * `TExtra` is for params the path does not name — query or body fields.
227
+ *
228
+ * Returns an ordinary `Request`, so `createApi` needs no knowledge of this
229
+ * function: its `ExtractParams`/`ExtractResponse` already match structurally.
230
+ * `new Request(...)` remains and is not deprecated; this is a second way to
231
+ * construct, which is what makes it additive.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
236
+ * const listRepos = defineRequest<Repo[], { page?: number }>()({
237
+ * method: 'GET',
238
+ * path: '/orgs/:org/repos',
239
+ * })
240
+ *
241
+ * api.getUser({ id: '42' }) // ✓
242
+ * api.getUser({ userId: '42' }) // ✗ compile error
243
+ * api.listRepos({ org: 'acme', page: 2 }) // ✓
244
+ * ```
245
+ */
246
+ export declare function defineRequest<TResponse = unknown, TExtra extends object = {}>(): <TPath extends string, TRT extends ResponseType | undefined, TSchema extends StandardSchemaV1<any> | undefined = undefined>(config: Omit<RequestConfig, "schema"> & {
247
+ path: TPath;
248
+ responseType?: TRT;
249
+ schema?: TSchema;
250
+ } & EmptyBodyGuard<TRT, TResponse> & SchemaConflictGuard<TSchema, TResponse> & FragmentGuard<TPath>) => Request<Id<PathParams<TPath> & TExtra>, unknown extends SchemaOut<TSchema> ? TResponse : SchemaOut<TSchema>>;
251
+ export {};
@@ -0,0 +1,4 @@
1
+ import { Request } from './request.js';
2
+ export function defineRequest() {
3
+ return (config) => new Request(config);
4
+ }
@@ -0,0 +1,30 @@
1
+ import type { CallOptions, Result, GraphQLBaseConfig, OperationConfig } from './types.js';
2
+ export declare class Operation<TVariables extends object, TData> {
3
+ readonly config: OperationConfig;
4
+ readonly _variables: TVariables;
5
+ readonly _data: TData;
6
+ constructor(config: OperationConfig);
7
+ }
8
+ export declare const gql: (strings: TemplateStringsArray, ...values: unknown[]) => string;
9
+ type GraphQLMethod<TVariables extends object, TData> = Record<string, never> extends TVariables ? (variables?: TVariables, options?: CallOptions) => Promise<Result<TData>> : (variables: TVariables, options?: CallOptions) => Promise<Result<TData>>;
10
+ type FlatClient<TOperations> = {
11
+ [K in keyof TOperations]: TOperations[K] extends Operation<infer V, infer D> ? GraphQLMethod<V, D> : never;
12
+ };
13
+ type SplitClient<TQ, TM> = (TQ extends Record<string, Operation<any, any>> ? {
14
+ query: FlatClient<TQ>;
15
+ } : {}) & (TM extends Record<string, Operation<any, any>> ? {
16
+ mutation: FlatClient<TM>;
17
+ } : {});
18
+ type WithOperations<T> = GraphQLBaseConfig & {
19
+ operations: T;
20
+ queries?: never;
21
+ mutations?: never;
22
+ };
23
+ type WithSplit<TQ, TM> = GraphQLBaseConfig & {
24
+ operations?: never;
25
+ queries?: TQ;
26
+ mutations?: TM;
27
+ };
28
+ export declare function createGraphQL<T extends Record<string, Operation<any, any>>>(config: WithOperations<T>): FlatClient<T>;
29
+ export declare function createGraphQL<TQ extends Record<string, Operation<any, any>>, TM extends Record<string, Operation<any, any>>>(config: WithSplit<TQ, TM>): SplitClient<TQ, TM>;
30
+ export {};