UNPKG

@hyper-fetch/core

Version:

Cache, Queue and Persist your requests no matter if you are online or offline!

341 lines (306 loc) 12.8 kB
import type { ClientInstance } from "client"; import type { Request, RequestInstance } from "request"; import type { ExtractEndpointType, ExtractResponseType, ExtractPayloadType, ExtractQueryParamsType, ExtractLocalErrorType, ExtractHasPayloadType, ExtractHasParamsType, ExtractHasQueryParamsType, ExtractMutationContextType, } from "types"; export type RecursiveSchemaType = Record< string, // for example users / $userId / posts / $postId any >; /** * Recursively walks a schema and rebuilds every `Request` leaf with the SDK's actual client * type injected into the `Client` generic slot. Lets users omit `client` from their * `RequestModel<{...}>` declarations - the SDK fills it in from the client passed to * `createSdk(client)`. * * Non-Request leaves (primitive values, plain types) are passed through unchanged. Nested * schema objects are recursed into. Depth is guarded at 10 levels to keep the type checker * within reasonable bounds. */ export type InjectClient<T, TClient extends ClientInstance, Depth extends unknown[] = []> = Depth["length"] extends 10 ? T : T extends RequestInstance ? Request< ExtractResponseType<T>, ExtractPayloadType<T>, ExtractQueryParamsType<T>, ExtractLocalErrorType<T>, ExtractEndpointType<T> extends string ? ExtractEndpointType<T> : string, TClient, ExtractHasPayloadType<T> extends boolean ? ExtractHasPayloadType<T> : false, ExtractHasParamsType<T> extends boolean ? ExtractHasParamsType<T> : false, ExtractHasQueryParamsType<T> extends boolean ? ExtractHasQueryParamsType<T> : false, ExtractMutationContextType<T> > : T extends Record<string, any> ? { [K in keyof T]: InjectClient<T[K], TClient, [...Depth, unknown]> } : T; /** * Per-request defaults that can be applied via SDK configuration. * These mirror the chainable setters on Request (headers, cache, retry, etc.). */ export type SdkRequestDefaults = { headers?: HeadersInit; auth?: boolean; cache?: boolean; cacheTime?: number; staleTime?: number; retry?: number; retryTime?: number; cancelable?: boolean; queued?: boolean; offline?: boolean; deduplicate?: boolean; deduplicateTime?: number | null; }; /** * Configuration value: either a static property bag or a function that receives * the request and returns a modified request (full access to every Request setter). */ export type SdkConfigurationValue = SdkRequestDefaults | ((request: RequestInstance) => RequestInstance); /** * Recursively extracts all endpoint strings from an SDK schema type. * Leaf nodes (keys starting with $) are RequestInstance — extract endpoint from those. * Other keys are nested schema segments to recurse into. */ type ExtractSdkEndpoints<T, Depth extends unknown[] = []> = Depth["length"] extends 10 ? never : { [K in keyof T]: K extends `$${string}` ? T[K] extends RequestInstance ? ExtractEndpointType<T[K]> : never : T[K] extends Record<string, any> ? ExtractSdkEndpoints<T[K], [...Depth, unknown]> : never; }[keyof T]; /** * Recursively extracts dot-path accessor keys from an SDK schema. * These mirror the SDK property chain: "users.$get", "users.$userId.$get", etc. */ type ExtractSdkPaths<T, Prefix extends string = "", Depth extends unknown[] = []> = Depth["length"] extends 10 ? never : { [K in keyof T & string]: T[K] extends RequestInstance ? `${Prefix}${K}` : T[K] extends Record<string, any> ? ExtractSdkPaths<T[K], `${Prefix}${K}.`, [...Depth, unknown]> : never; }[keyof T & string]; /** * Builds the set of valid configuration keys from an SDK schema: * - "*" (global wildcard) * - exact endpoint paths extracted from RequestInstance nodes (group keys) * - wildcard patterns like "/users/*" (group keys) * - dot-path accessor keys like "users.$get" (method-specific keys) */ type SdkConfigurationKeys<Schema extends RecursiveSchemaType> = | "*" | ExtractSdkEndpoints<Schema> | `${ExtractSdkEndpoints<Schema> & string}/*` | ExtractSdkPaths<Schema>; /** * Maps endpoint paths, wildcard patterns, or dot-path accessor keys to request defaults. * Keys are validated against the SDK schema. * * Values can be: * - A plain `SdkRequestDefaults` object (shorthand for common settings) * - A function `(request) => request` for full access to every Request setter */ export type SdkConfigurationMap<Schema extends RecursiveSchemaType = RecursiveSchemaType> = Partial< Record<SdkConfigurationKeys<Schema>, SdkConfigurationValue> >; export type CreateSdkOptions<Schema extends RecursiveSchemaType = RecursiveSchemaType> = { /** @default true */ camelCaseToKebabCase?: boolean; /** @default (method) => method.toUpperCase() */ methodTransform?: (method: string) => string; /** Per-endpoint request defaults */ defaults?: SdkConfigurationMap<Schema>; }; const getMethod = (key: string, options?: CreateSdkOptions<any>) => { const { methodTransform = (method: string) => method.toUpperCase() } = options ?? {}; return methodTransform(key); }; const endpointMatchesPattern = (endpoint: string, pattern: string): boolean => { if (pattern === "*") return true; if (pattern === endpoint) return true; // Simple wildcard: "/users/*" matches "/users/:userId", "/users/:userId/posts", etc. if (pattern.endsWith("/*")) { const prefix = pattern.slice(0, -1); return endpoint.startsWith(prefix) || endpoint === prefix.slice(0, -1); } return false; }; const isDotPath = (key: string): boolean => { return key.includes(".") && !key.startsWith("/"); }; const applyObjectDefaults = (request: RequestInstance, config: SdkRequestDefaults): RequestInstance => { let result = request; if (config.headers) result = result.setHeaders(config.headers); if (config.auth !== undefined) result = result.setAuth(config.auth); if (config.cache !== undefined) result = result.setCache(config.cache); if (config.cacheTime !== undefined) result = result.setCacheTime(config.cacheTime); if (config.staleTime !== undefined) result = result.setStaleTime(config.staleTime); if (config.retry !== undefined) result = result.setRetry(config.retry); if (config.retryTime !== undefined) result = result.setRetryTime(config.retryTime); if (config.cancelable !== undefined) result = result.setCancelable(config.cancelable); if (config.queued !== undefined) result = result.setQueued(config.queued); if (config.offline !== undefined) result = result.setOffline(config.offline); if (config.deduplicate !== undefined) result = result.setDeduplicate(config.deduplicate); if (config.deduplicateTime !== undefined && config.deduplicateTime !== null) { result = result.setDeduplicateTime(config.deduplicateTime); } return result; }; const applyDefaults = ( request: RequestInstance, context: { endpoint: string; sdkPath: string; defaults?: Partial<Record<string, SdkConfigurationValue>> }, ): RequestInstance => { const { endpoint, sdkPath, defaults } = context; if (!defaults) return request; let result = request; const entries = Object.entries(defaults); // Sort: "*" first, then endpoint groups (start with "/"), then dot-path (method-specific) last const globalEntries: [string, SdkConfigurationValue][] = []; const groupEntries: [string, SdkConfigurationValue][] = []; const pathEntries: [string, SdkConfigurationValue][] = []; for (let i = 0; i < entries.length; i += 1) { const [key, value] = entries[i]; if (!value) continue; const entry: [string, SdkConfigurationValue] = [key, value]; if (entry[0] === "*") { globalEntries.push(entry); } else if (isDotPath(entry[0])) { pathEntries.push(entry); } else { groupEntries.push(entry); } } const sorted = [...globalEntries, ...groupEntries, ...pathEntries]; for (let i = 0; i < sorted.length; i += 1) { const [pattern, config] = sorted[i]; let matches = false; if (isDotPath(pattern)) { matches = pattern === sdkPath; } else { matches = endpointMatchesPattern(endpoint, pattern); } if (matches) { if (typeof config === "function") { result = config(result); } else { result = applyObjectDefaults(result, config); } } } return result; }; const createRecursiveProxy = ( client: ClientInstance, context: { path: string[]; sdkKeys: string[]; options?: CreateSdkOptions<any> }, ): any => { const { path, sdkKeys, options } = context; // eslint-disable-next-line @typescript-eslint/no-empty-function return new Proxy(() => {}, { get: (_target, key: string) => { if (typeof key === "symbol" || key === "inspect") { return undefined; } // Check if this is a method (starts with $) or a path segment let isMethod = false; let methodName = key; let pathSegment = key; if (key.startsWith("$")) { isMethod = true; methodName = key.slice(1); pathSegment = `:${key.slice(1)}`; } else if (options?.camelCaseToKebabCase) { pathSegment = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); } // Build the sdk dot-path for method-specific configuration matching const currentSdkKeys = [...sdkKeys, key]; const sdkPath = currentSdkKeys.join("."); // Always create a request assuming this is a method call const endpoint = `/${path.join("/")}`; const method = getMethod(isMethod ? methodName : key, options); let request: RequestInstance = client.createRequest()({ endpoint, method }); request = applyDefaults(request, { endpoint, sdkPath, defaults: options?.defaults }); // But also, assume the key is a new path segment for a deeper call const newPath = [...path, pathSegment]; const deeperProxy = createRecursiveProxy(client, { path: newPath, sdkKeys: currentSdkKeys, options }); // Return a new proxy that wraps both the request and the deeper proxy return new Proxy(request, { get: (reqTarget, reqKey: string) => { // If the property exists on the request instance (like .send(), .setParams()), return it. if (reqKey in reqTarget) { return reqTarget[reqKey as keyof typeof reqTarget]; } // Otherwise, it's a deeper path, so delegate to the deeper proxy. return deeperProxy[reqKey]; }, }); }, }); }; /** * The fully-resolved SDK instance type returned by `createSdk`. * * The schema is rewritten via {@link InjectClient} so every `Request` leaf carries the * actual client type passed to `createSdk(client)` - users do not need to repeat * `client: AppClient` in every `RequestModel<{...}>` declaration. */ export type SdkInstance< Schema extends RecursiveSchemaType, TClient extends ClientInstance = ClientInstance, > = InjectClient<Schema, TClient> & { /** * Apply request defaults to the SDK. Returns a new SDK instance with the configuration applied. * Use "*" to match all endpoints, or specific endpoint strings / wildcard patterns. */ $configure: (defaults: SdkConfigurationMap<Schema>) => SdkInstance<Schema, TClient>; }; export const createSdk = <Client extends ClientInstance, RecursiveSchema extends RecursiveSchemaType>( client: Client, options?: CreateSdkOptions<RecursiveSchema>, ): SdkInstance<RecursiveSchema, Client> => { const { camelCaseToKebabCase = true, methodTransform = (method: string) => method.toUpperCase(), ...rest } = options ?? {}; const mergedOptions: CreateSdkOptions<RecursiveSchema> = { camelCaseToKebabCase, methodTransform, ...rest }; // Break inference for TypeDoc / tsserver (TS2589) — runtime value is unchanged. const proxy = createRecursiveProxy(client, { path: [], sdkKeys: [], options: mergedOptions as never }); return new Proxy(proxy, { get: (target, key: string) => { if (key === "$configure") { return (defaults: SdkConfigurationMap<RecursiveSchema>) => { const existingDefaults = mergedOptions.defaults || {}; return createSdk<Client, RecursiveSchema>(client, { ...mergedOptions, defaults: { ...existingDefaults, ...defaults } as SdkConfigurationMap<RecursiveSchema>, }); }; } return target[key]; }, }) as SdkInstance<RecursiveSchema, Client>; }; /** * Type-safe factory for creating SDK configuration maps. * * @example * const config = createConfiguration<MySdkSchema>()({ "*": { retry: 3 } }) */ export const createConfiguration = <Schema extends RecursiveSchemaType>() => { return (defaults: SdkConfigurationMap<Schema>): SdkConfigurationMap<Schema> => defaults; };