@zerotal/client 1.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.
package/src/augment.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ApiClient } from "./ApiClient.ts";
2
+ import type { ApiRouteMap } from "./types.ts";
3
+
4
+ declare module "@zerotal/core" {
5
+ interface ContainerBindings {
6
+ client: ApiClient<ApiRouteMap>;
7
+ }
8
+ }
package/src/config.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import type { ApiClientConfig } from "./ApiClient.ts";
3
+
4
+ export interface ClientConfigShape extends ApiClientConfig {
5
+ baseUrl?: string;
6
+ }
7
+
8
+ // The client carries no framework-level defaults of its own — `ApiClient` applies
9
+ // its own internal defaults. deepMerge still gives a fresh, isolated copy of the
10
+ // caller's options (and keeps every config factory on the one canonical merge).
11
+ const defaults: Partial<ClientConfigShape> = {};
12
+
13
+ export function ClientConfig(options: Partial<ClientConfigShape> = {}): Partial<ClientConfigShape> {
14
+ return deepMerge(defaults, options);
15
+ }
16
+
17
+ // Register this package's config namespace for typed config() dot-paths.
18
+ declare module "@zerotal/core" {
19
+ interface ConfigRegistry {
20
+ client: ClientConfigShape;
21
+ }
22
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown for any non-2xx HTTP response.
5
+ *
6
+ * `body` is the raw response text. For `422` validation failures the more specific
7
+ * {@link ValidationError} subclass is thrown instead, exposing the parsed field errors.
8
+ */
9
+ export class ApiClientError extends ZerotalError {
10
+ /** Response headers, when available (used for `Retry-After`, rate-limit info, etc.). */
11
+ readonly headers: Headers | undefined;
12
+
13
+ constructor(
14
+ status: number,
15
+ readonly statusText: string,
16
+ readonly body: string,
17
+ headers?: Headers,
18
+ ) {
19
+ super(
20
+ `[API] ${status} ${statusText}${body ? ": " + body.slice(0, 200) : ""}`,
21
+ "E_API_CLIENT",
22
+ status,
23
+ );
24
+ this.headers = headers;
25
+ }
26
+
27
+ /** Parsed `Retry-After` (seconds or HTTP-date) in milliseconds, or `null` when absent. */
28
+ get retryAfterMs(): number | null {
29
+ return parseRetryAfter(this.headers?.get("retry-after"));
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Thrown for `422 Unprocessable Entity` responses whose body matches the framework's
35
+ * validation shape (`{ message, errors: { field: string[] } }`, as produced by
36
+ * `@zerotal/validator`). Use the helpers to read field errors without re-parsing:
37
+ *
38
+ * @example
39
+ * try {
40
+ * await api.post("/api/users", form);
41
+ * } catch (e) {
42
+ * if (e instanceof ValidationError) {
43
+ * setFieldErrors(e.errors); // { email: ["…"], password: ["…"] }
44
+ * if (e.has("email")) showError(e.first("email"));
45
+ * }
46
+ * }
47
+ */
48
+ export class ValidationError extends ApiClientError {
49
+ /** Field → messages map (always an array per field). */
50
+ readonly errors: Record<string, string[]>;
51
+ /** The top-level `message` from the response (e.g. "The given data was invalid."). */
52
+ readonly validationMessage: string;
53
+
54
+ constructor(
55
+ statusText: string,
56
+ body: string,
57
+ errors: Record<string, string[]>,
58
+ validationMessage: string,
59
+ headers?: Headers,
60
+ ) {
61
+ super(422, statusText, body, headers);
62
+ this.errors = errors;
63
+ this.validationMessage = validationMessage;
64
+ }
65
+
66
+ /** True when the given field has at least one error. */
67
+ has(field: string): boolean {
68
+ return (this.errors[field]?.length ?? 0) > 0;
69
+ }
70
+
71
+ /** The first error message for a field, or `undefined`. */
72
+ first(field: string): string | undefined {
73
+ return this.errors[field]?.[0];
74
+ }
75
+
76
+ /** All field errors. */
77
+ all(): Record<string, string[]> {
78
+ return this.errors;
79
+ }
80
+
81
+ /** The names of every field that failed. */
82
+ fields(): string[] {
83
+ return Object.keys(this.errors);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Build the right error for a non-2xx response — a {@link ValidationError} when the status is
89
+ * 422 and the body carries an `errors` bag, otherwise a plain {@link ApiClientError}.
90
+ */
91
+ export function makeApiError(
92
+ status: number,
93
+ statusText: string,
94
+ body: string,
95
+ headers?: Headers,
96
+ ): ApiClientError {
97
+ if (status === 422) {
98
+ const parsed = _parseValidationBody(body);
99
+ if (parsed) {
100
+ return new ValidationError(statusText, body, parsed.errors, parsed.message, headers);
101
+ }
102
+ }
103
+ return new ApiClientError(status, statusText, body, headers);
104
+ }
105
+
106
+ /** Parse a `{ message?, errors: { field: string | string[] } }` body, or null if it doesn't match. */
107
+ function _parseValidationBody(
108
+ body: string,
109
+ ): { errors: Record<string, string[]>; message: string } | null {
110
+ let json: unknown;
111
+ try {
112
+ json = JSON.parse(body);
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (!json || typeof json !== "object") return null;
117
+ const raw = (json as { errors?: unknown }).errors;
118
+ if (!raw || typeof raw !== "object") return null;
119
+
120
+ const errors: Record<string, string[]> = {};
121
+ for (const [field, val] of Object.entries(raw as Record<string, unknown>)) {
122
+ errors[field] = Array.isArray(val) ? val.map(String) : [String(val)];
123
+ }
124
+ const message = (json as { message?: unknown }).message;
125
+ return { errors, message: typeof message === "string" ? message : "The given data was invalid." };
126
+ }
127
+
128
+ /** Parse an HTTP `Retry-After` header (delta-seconds or HTTP-date) to milliseconds. */
129
+ export function parseRetryAfter(value: string | null | undefined): number | null {
130
+ if (!value) return null;
131
+ const seconds = Number(value);
132
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
133
+ const date = Date.parse(value);
134
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
135
+ return null;
136
+ }
package/src/http.ts ADDED
@@ -0,0 +1,192 @@
1
+ import { ApiClientError } from "./errors.ts";
2
+ import { CircuitBreakerOpenError } from "./CircuitBreaker.ts";
3
+
4
+ // ── Path & query ───────────────────────────────────────────────────────────────
5
+
6
+ /** Replace `{param}` placeholders in a path; throws on a missing param. */
7
+ export function interpolatePath(
8
+ path: string,
9
+ params: Record<string, string | number> | undefined,
10
+ ): string {
11
+ if (!params) return path;
12
+ return path.replace(/\{(\w+)\}/g, (_, key: string) => {
13
+ const val = params[key];
14
+ if (val === undefined) {
15
+ throw new ApiClientError(0, "Bad Request", `Missing path parameter: "${key}"`);
16
+ }
17
+ return String(val);
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Serialize a query object to a string, supporting nested arrays/objects with bracket notation:
23
+ * { ids: [1, 2] } → ids[]=1&ids[]=2
24
+ * { filter: { status: "open" } } → filter[status]=open
25
+ * { page: 2, active: true } → page=2&active=true
26
+ * `undefined` and `null` values are skipped.
27
+ */
28
+ export function serializeQuery(query: Record<string, unknown> | undefined): string {
29
+ if (!query) return "";
30
+ const pairs: [string, string][] = [];
31
+ for (const [key, value] of Object.entries(query)) _appendQuery(pairs, key, value);
32
+ if (pairs.length === 0) return "";
33
+ return "?" + new URLSearchParams(pairs).toString();
34
+ }
35
+
36
+ function _appendQuery(pairs: [string, string][], key: string, value: unknown): void {
37
+ if (value === undefined || value === null) return;
38
+ if (Array.isArray(value)) {
39
+ for (const item of value) _appendQuery(pairs, `${key}[]`, item);
40
+ } else if (typeof value === "object" && !(value instanceof Date)) {
41
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
42
+ _appendQuery(pairs, `${key}[${k}]`, v);
43
+ }
44
+ } else {
45
+ pairs.push([key, value instanceof Date ? value.toISOString() : String(value)]);
46
+ }
47
+ }
48
+
49
+ export function buildUrl(base: string, path: string, query?: Record<string, unknown>): string {
50
+ return base.replace(/\/+$/, "") + path + serializeQuery(query);
51
+ }
52
+
53
+ // ── Body serialization ──────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Turn a request body into a `BodyInit`. Plain objects/arrays are JSON-encoded (and get a JSON
57
+ * Content-Type); `FormData`, `Blob`/`File`, `URLSearchParams`, `ArrayBuffer`/typed arrays, and
58
+ * raw strings pass straight through so `fetch` can set the appropriate Content-Type itself
59
+ * (e.g. multipart boundaries for `FormData`).
60
+ */
61
+ export function serializeBody(body: unknown): { body: BodyInit | undefined; contentType?: string } {
62
+ if (body === undefined || body === null) return { body: undefined };
63
+ if (typeof body === "string") return { body };
64
+ if (
65
+ body instanceof FormData ||
66
+ body instanceof URLSearchParams ||
67
+ body instanceof Blob ||
68
+ body instanceof ArrayBuffer ||
69
+ ArrayBuffer.isView(body)
70
+ ) {
71
+ return { body: body as BodyInit };
72
+ }
73
+ return { body: JSON.stringify(body), contentType: "application/json" };
74
+ }
75
+
76
+ // ── Timeout / abort ──────────────────────────────────────────────────────────────
77
+
78
+ /** Combine a caller-supplied signal with a timeout into one `AbortSignal` (fresh per attempt). */
79
+ export function timeoutSignal(
80
+ timeoutMs?: number,
81
+ userSignal?: AbortSignal,
82
+ ): AbortSignal | undefined {
83
+ const signals: AbortSignal[] = [];
84
+ if (userSignal) signals.push(userSignal);
85
+ if (timeoutMs && timeoutMs > 0) signals.push(AbortSignal.timeout(timeoutMs));
86
+ if (signals.length === 0) return undefined;
87
+ return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
88
+ }
89
+
90
+ // ── Cookies ──────────────────────────────────────────────────────────────────────
91
+
92
+ /** Read a cookie value from `document.cookie` (URL-decoded), or undefined outside the browser.
93
+ * Local parser by design: this package runs in browsers and cannot import
94
+ * server-side @zerotal/core (whose parseCookieHeader covers the server case). */
95
+ export function readCookie(name: string): string | undefined {
96
+ const doc = (globalThis as { document?: { cookie?: string } }).document;
97
+ if (!doc?.cookie) return undefined;
98
+ for (const part of doc.cookie.split(";")) {
99
+ const eq = part.indexOf("=");
100
+ if (eq === -1) continue;
101
+ if (part.slice(0, eq).trim() === name) {
102
+ return decodeURIComponent(part.slice(eq + 1).trim());
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ // ── Retry policy ───────────────────────────────────────────────────────────────
109
+
110
+ export interface RetryOptions {
111
+ /** Max retry attempts after the first try. Default: 2. */
112
+ attempts?: number;
113
+ /** HTTP methods eligible for retry. Default: idempotent verbs (GET/PUT/DELETE/HEAD/OPTIONS). */
114
+ methods?: string[];
115
+ /** HTTP statuses that should be retried. Default: 408, 425, 429, 500, 502, 503, 504. */
116
+ statuses?: number[];
117
+ /** Honor a `Retry-After` header when present. Default: true. */
118
+ respectRetryAfter?: boolean;
119
+ /** Compute the delay (ms) before attempt N. Default: exponential backoff + jitter. */
120
+ backoff?: (attempt: number, retryAfterMs: number | null) => number;
121
+ }
122
+
123
+ const DEFAULT_RETRY: Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> = {
124
+ attempts: 2,
125
+ methods: ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"],
126
+ statuses: [408, 425, 429, 500, 502, 503, 504],
127
+ respectRetryAfter: true,
128
+ };
129
+
130
+ /** Resolve a `retry` config value to concrete options, or `null` when retry is disabled. */
131
+ export function resolveRetry(
132
+ retry: number | RetryOptions | false | undefined,
133
+ method: string,
134
+ ): Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> {
135
+ if (retry === undefined || retry === false || retry === 0) {
136
+ return { ...DEFAULT_RETRY, attempts: 0 };
137
+ }
138
+ const opts = typeof retry === "number" ? { attempts: retry } : retry;
139
+ const merged = { ...DEFAULT_RETRY, ...opts };
140
+ // Methods are matched case-insensitively.
141
+ if (!merged.methods.some((m) => m.toUpperCase() === method.toUpperCase())) {
142
+ return { ...merged, attempts: 0 };
143
+ }
144
+ return merged;
145
+ }
146
+
147
+ function _defaultBackoff(attempt: number, retryAfterMs: number | null): number {
148
+ if (retryAfterMs !== null) return Math.min(retryAfterMs, 30_000);
149
+ const base = Math.min(1000 * 2 ** attempt, 30_000); // 1s, 2s, 4s, …
150
+ return base + Math.floor(base * 0.2 * Math.random()); // up to +20% jitter
151
+ }
152
+
153
+ function _isRetryable(
154
+ err: unknown,
155
+ statuses: number[],
156
+ ): { retry: false } | { retry: true; retryAfterMs: number | null } {
157
+ // Circuit-breaker fast-fail — never retry.
158
+ if (err instanceof CircuitBreakerOpenError) return { retry: false };
159
+ // HTTP error with a retryable status.
160
+ if (err instanceof ApiClientError) {
161
+ return statuses.includes(err.status)
162
+ ? { retry: true, retryAfterMs: err.retryAfterMs }
163
+ : { retry: false };
164
+ }
165
+ // Abort: a timeout is transient (retry); a caller cancellation is not.
166
+ if (err instanceof Error && err.name === "TimeoutError")
167
+ return { retry: true, retryAfterMs: null };
168
+ if (err instanceof Error && err.name === "AbortError") return { retry: false };
169
+ // Anything else thrown by fetch is a network/transport error → retry.
170
+ return { retry: true, retryAfterMs: null };
171
+ }
172
+
173
+ /** Run `fn`, retrying transient failures per `policy`. */
174
+ export async function withRetry<T>(
175
+ fn: () => Promise<T>,
176
+ policy: Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff">,
177
+ ): Promise<T> {
178
+ const backoff = policy.backoff ?? _defaultBackoff;
179
+ let attempt = 0;
180
+ for (;;) {
181
+ try {
182
+ return await fn();
183
+ } catch (err) {
184
+ if (attempt >= policy.attempts) throw err;
185
+ const verdict = _isRetryable(err, policy.statuses);
186
+ if (!verdict.retry) throw err;
187
+ const retryAfter = policy.respectRetryAfter ? verdict.retryAfterMs : null;
188
+ await new Promise((r) => setTimeout(r, backoff(attempt, retryAfter)));
189
+ attempt++;
190
+ }
191
+ }
192
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ export type {
2
+ ApiRouteMap,
3
+ RouteShape,
4
+ HttpMethod,
5
+ PathParams,
6
+ ParamRecord,
7
+ PathsFor,
8
+ ResponseOf,
9
+ BodyOf,
10
+ QueryOf,
11
+ } from "./types.ts";
12
+
13
+ export { ApiClient, ApiClientError, ValidationError } from "./ApiClient.ts";
14
+ export type {
15
+ ApiClientConfig,
16
+ RequestConfig,
17
+ RequestInterceptor,
18
+ ResponseContext,
19
+ ResponseInterceptor,
20
+ ResponseMeta,
21
+ RequestOptions,
22
+ GetOptions,
23
+ MutationOptions,
24
+ TokenSource,
25
+ RetryOptions,
26
+ } from "./ApiClient.ts";
27
+
28
+ export { CircuitBreaker, CircuitBreakerOpenError } from "./CircuitBreaker.ts";
29
+ export type { CircuitBreakerOptions, CircuitState } from "./CircuitBreaker.ts";
30
+
31
+ // Realtime broadcasting client (native @zerotal/broadcasting protocol; familiar realtime-client API).
32
+ export { Socket, Channel, PresenceChannel } from "./Socket.ts";
33
+ export type { SocketOptions, SocketState, SocketLike, PresenceMember } from "./Socket.ts";
34
+
35
+ export { ClientProvider, Client } from "./provider/ClientProvider.ts";
36
+ export { ClientConfig } from "./config.ts";
37
+ export type { ClientConfigShape } from "./config.ts";
38
+
39
+ // ── Factory function ──────────────────────────────────────────────────────────
40
+
41
+ import { ApiClient } from "./ApiClient.ts";
42
+ import type { ApiRouteMap } from "./types.ts";
43
+ import type { ApiClientConfig } from "./ApiClient.ts";
44
+
45
+ /**
46
+ * Create a type-safe API client bound to your route map.
47
+ *
48
+ * @example
49
+ * // frontend/api.ts
50
+ * import { createApiClient } from '@zerotal/client';
51
+ * import type { Routes } from './routes';
52
+ *
53
+ * export const api = createApiClient<Routes>({
54
+ * baseUrl: '/api',
55
+ * headers: { 'X-Requested-With': 'XMLHttpRequest' },
56
+ * });
57
+ *
58
+ * // Anywhere in your UI:
59
+ * const user = await api.get('/users/{id}', { id: 1 });
60
+ * // ^? UserResource (inferred from Routes)
61
+ *
62
+ * const newUser = await api.post('/users', { name: 'Alice', email: 'alice@ex.com' });
63
+ */
64
+ export function createApiClient<Routes extends ApiRouteMap>(
65
+ config: ApiClientConfig = {},
66
+ ): ApiClient<Routes> {
67
+ return new ApiClient<Routes>(config);
68
+ }
@@ -0,0 +1,26 @@
1
+ import { ServiceProvider, createFacade } from "@zerotal/core";
2
+ import type { AppEnvironment } from "@zerotal/core";
3
+ import { ApiClient } from "../ApiClient.ts";
4
+ import type { ClientConfigShape } from "../config.ts";
5
+ import "../augment.ts";
6
+
7
+ export class ClientProvider extends ServiceProvider {
8
+ static override provides = ["client"] as const;
9
+ static override environments: AppEnvironment[] = ["web", "console", "worker", "test", "repl"];
10
+
11
+ override onRegister(): void {
12
+ this.app.container.singleton("client", async (c) => {
13
+ const cfg = (await c.make("config")) as { get<T>(path: string): T | undefined };
14
+ const options: ClientConfigShape = cfg.get<ClientConfigShape>("client") ?? {};
15
+ return new ApiClient(options);
16
+ });
17
+ }
18
+
19
+ // Pre-resolve the async singleton so the `Client` facade's makeSync access
20
+ // works after boot (an async factory needs one `await make()` first).
21
+ override async onBooted(): Promise<void> {
22
+ await this.app.container.make("client");
23
+ }
24
+ }
25
+
26
+ export const Client = createFacade<"client">("client");
package/src/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ // ── Path parameter extraction ─────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Extract the names of all `{param}` placeholders from a URL path string.
5
+ *
6
+ * @example
7
+ * PathParams<'/api/users/{id}/posts/{postId}'>
8
+ * // → 'id' | 'postId'
9
+ */
10
+ export type PathParams<Path extends string> = Path extends `${string}{${infer Param}}${infer Tail}`
11
+ ? Param | PathParams<Tail>
12
+ : never;
13
+
14
+ /**
15
+ * Build a record type from the path param names.
16
+ * When there are no params, resolves to `undefined` so the argument is omittable.
17
+ *
18
+ * @example
19
+ * ParamRecord<'/api/users/{id}'> → { id: string | number }
20
+ * ParamRecord<'/api/users'> → undefined
21
+ */
22
+ export type ParamRecord<Path extends string> = [PathParams<Path>] extends [never]
23
+ ? undefined
24
+ : { [K in PathParams<Path>]: string | number };
25
+
26
+ // ── Route map shape ───────────────────────────────────────────────────────────
27
+
28
+ /** HTTP verbs used as route-key prefixes. */
29
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
30
+
31
+ /**
32
+ * Shape of a single route entry in the `ApiRouteMap`.
33
+ *
34
+ * All fields are optional — omit any that don't apply to a given route.
35
+ */
36
+ export interface RouteShape {
37
+ /** URL path parameters, e.g. `{ id: string | number }` */
38
+ params?: Record<string, string | number>;
39
+ /** Query-string parameters */
40
+ query?: Record<string, string | number | boolean | undefined>;
41
+ /** Request body for POST/PUT/PATCH */
42
+ body?: Record<string, unknown>;
43
+ /** The JSON response body type */
44
+ response?: unknown;
45
+ }
46
+
47
+ /**
48
+ * The route type map you write (or generate) for your API.
49
+ *
50
+ * Keys are `'METHOD /path'` strings — e.g. `'GET /api/users/{id}'`.
51
+ *
52
+ * @example
53
+ * export interface Routes {
54
+ * 'GET /api/users': {
55
+ * query: { page?: number; perPage?: number };
56
+ * response: { data: UserResource[]; total: number };
57
+ * };
58
+ * 'GET /api/users/{id}': {
59
+ * params: { id: string | number };
60
+ * response: UserResource;
61
+ * };
62
+ * 'POST /api/users': {
63
+ * body: { name: string; email: string; password: string };
64
+ * response: UserResource;
65
+ * };
66
+ * 'DELETE /api/users/{id}': {
67
+ * params: { id: string | number };
68
+ * response: void;
69
+ * };
70
+ * }
71
+ */
72
+ export type ApiRouteMap = Partial<Record<`${HttpMethod} ${string}`, RouteShape>>;
73
+
74
+ // ── Accessor helpers ──────────────────────────────────────────────────────────
75
+
76
+ /** Extract all paths registered for a given HTTP method from a route map. */
77
+ export type PathsFor<Routes extends ApiRouteMap, Method extends HttpMethod> = {
78
+ [K in keyof Routes & string]: K extends `${Method} ${infer Path}` ? Path : never;
79
+ }[keyof Routes & string];
80
+
81
+ /** Get the `response` type for a specific method + path combination. */
82
+ export type ResponseOf<
83
+ Routes extends ApiRouteMap,
84
+ Method extends HttpMethod,
85
+ Path extends string,
86
+ > = `${Method} ${Path}` extends keyof Routes
87
+ ? Routes[`${Method} ${Path}`] extends { response: infer R }
88
+ ? R
89
+ : unknown
90
+ : unknown;
91
+
92
+ /** Get the `body` type for a specific method + path combination. */
93
+ export type BodyOf<
94
+ Routes extends ApiRouteMap,
95
+ Method extends HttpMethod,
96
+ Path extends string,
97
+ > = `${Method} ${Path}` extends keyof Routes
98
+ ? Routes[`${Method} ${Path}`] extends { body: infer B }
99
+ ? B
100
+ : Record<string, unknown>
101
+ : Record<string, unknown>;
102
+
103
+ /** Get the `query` type for a specific method + path combination. */
104
+ export type QueryOf<
105
+ Routes extends ApiRouteMap,
106
+ Method extends HttpMethod,
107
+ Path extends string,
108
+ > = `${Method} ${Path}` extends keyof Routes
109
+ ? Routes[`${Method} ${Path}`] extends { query: infer Q }
110
+ ? Q
111
+ : Record<string, string | number | boolean | undefined>
112
+ : Record<string, string | number | boolean | undefined>;