@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
text/typescript
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;
};