// Type-safe fetch-like client for Spiceflow. Uses a familiar fetch(path, options) // interface instead of the proxy-based chainable API. import type { AnySpiceflow, Spiceflow } from '../spiceflow.ts' import type { ExtractParamsFromPath } from '../types.ts' import type { SpiceflowClient } from './types.ts' import type { ReplaceGeneratorWithAsyncGenerator } from './types.ts' import { SpiceflowFetchError } from './errors.ts' import { processHeaders, buildQueryString, serializeBody, parseResponseData, executeWithRetries, } from './shared.ts' // ─── Type utilities ────────────────────────────────────────────────────────── type HttpMethodLower = | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head' | 'connect' | 'subscribe' // Navigate the nested ClientRoutes tree given a path string. // Reverses what CreateClient does: `/users/:id` → Routes['users'][':id'] type NavigateRoutes = Path extends `/${infer Rest}` ? Rest extends '' ? 'index' extends keyof Routes ? Routes['index'] : never : _NavigateRoutes : _NavigateRoutes type _NavigateRoutes = Path extends `${infer Segment}/${infer Rest}` ? Segment extends keyof Routes ? _NavigateRoutes : never : Path extends keyof Routes ? Routes[Path] : never type RouteAtPath< Routes extends Record, Path extends string, > = NavigateRoutes type MethodsAtPath< Routes extends Record, Path extends string, > = Extract, HttpMethodLower> type AllowedMethod< Routes extends Record, Path extends string, > = Uppercase> | MethodsAtPath type RouteInfoForMethod< Routes extends Record, Path extends string, Method extends string, > = Lowercase extends keyof RouteAtPath ? RouteAtPath[Lowercase] : never // ─── Options type ──────────────────────────────────────────────────────────── // Params option: required if path has :params, omitted otherwise type ParamsOption = ExtractParamsFromPath extends undefined ? { params?: Record } : { params: ExtractParamsFromPath } // Query option: typed from route schema if available type QueryOption< Routes extends Record, Path extends string, Method extends string, > = RouteInfoForMethod extends { query: infer Q } ? undefined extends Q ? { query?: Record } : { query: Q } : { query?: Record } // Body option: typed from route schema, only for non-GET/HEAD/SUBSCRIBE methods type BodyOption< Routes extends Record, Path extends string, Method extends string, > = Lowercase extends 'get' | 'head' | 'subscribe' ? {} : RouteInfoForMethod extends { request: infer Body } ? undefined extends Body ? { body?: unknown } : { body: Body } : { body?: unknown } // Check if options has any required fields type HasRequiredFields< Routes extends Record, Path extends string, Method extends string, > = // params required? ExtractParamsFromPath extends undefined ? // query required? RouteInfoForMethod extends { query: infer Q } ? undefined extends Q ? // body required? Lowercase extends 'get' | 'head' | 'subscribe' ? false : RouteInfoForMethod extends { request: infer Body } ? undefined extends Body ? false : true : false : true : // body required? Lowercase extends 'get' | 'head' | 'subscribe' ? false : RouteInfoForMethod extends { request: infer Body } ? undefined extends Body ? false : true : false : true type FetchOptionsTyped< Routes extends Record, Path extends string, Method extends string, > = { method?: Method headers?: RequestInit['headers'] signal?: AbortSignal } & ParamsOption & QueryOption & BodyOption type FetchOptionsFallback = { method?: string body?: BodyInit | Record | null query?: Record params?: Record headers?: RequestInit['headers'] signal?: AbortSignal [key: string]: unknown } type FetchOptions< Routes extends Record, Path extends string, Method extends string, > = [RouteAtPath] extends [never] ? FetchOptionsFallback : FetchOptionsTyped // ─── Response type ─────────────────────────────────────────────────────────── type FetchResultData< Routes extends Record, Path extends string, Method extends string, > = [RouteAtPath] extends [never] ? any : RouteInfoForMethod extends { response: infer Res extends Record } ? ReplaceGeneratorWithAsyncGenerator[200] : any type FetchResultError< Routes extends Record, Path extends string, Method extends string, > = [RouteAtPath] extends [never] ? SpiceflowFetchError : RouteInfoForMethod extends { response: infer Res extends Record } ? Exclude extends never ? SpiceflowFetchError : { [Status in keyof Res]: SpiceflowFetchError }[Exclude] : SpiceflowFetchError type FetchResult< Routes extends Record, Path extends string, Method extends string, > = FetchResultError | FetchResultData // ─── Public type ───────────────────────────────────────────────────────────── // Resolves options for a given App/Path/Method combination. // Returns the appropriate options type, or FetchOptionsFallback for unknown paths. type ResolveOptions< App extends AnySpiceflow, Path extends string, Method extends string, > = App extends { _types: { ClientRoutes: infer Routes extends Record } } ? FetchOptions : FetchOptionsFallback // Resolves the result type for a given App/Path/Method combination. type ResolveResult< App extends AnySpiceflow, Path extends string, Method extends string, > = App extends { _types: { ClientRoutes: infer Routes extends Record } } ? FetchResult : SpiceflowFetchError | any // Check if options are required for a given App/Path/Method type IsOptionsRequired< App extends AnySpiceflow, Path extends string, Method extends string, > = App extends { _types: { ClientRoutes: infer Routes extends Record } } ? [RouteAtPath] extends [never] ? false : HasRequiredFields : false export interface SpiceflowFetch { // Overload: options required when route demands params/query/body ( ...args: IsOptionsRequired extends true ? [path: Path, options: ResolveOptions] : [path: Path, options?: ResolveOptions] ): Promise> } // ─── Factory ───────────────────────────────────────────────────────────────── export function createSpiceflowFetch( domain: App | string, config: SpiceflowClient.Config & (App extends Spiceflow ? { state?: Singleton['state'] } : {}) = {} as any, ): SpiceflowFetch { let baseUrl: string let instance: AnySpiceflow | undefined if (typeof domain === 'string') { baseUrl = domain.endsWith('/') ? domain.slice(0, -1) : domain } else { baseUrl = 'http://e.ly' instance = domain if (typeof window !== 'undefined') { console.warn( 'Spiceflow instance server found on client side, this is not recommended for security reason. Use generic type instead.', ) } } if ((config as any).state && !instance) { throw new Error('State is only available when using a Spiceflow instance') } const spiceflowFetch = async ( path: string, options: any = {}, ): Promise => { let { fetch: fetcher = fetch, headers: configHeaders, onRequest, onResponse, retries = 0, } = config as SpiceflowClient.Config const { method: rawMethod = 'GET', body, query, params, headers: optionHeaders, signal, ...restInit } = options const methodUpper = rawMethod.toUpperCase() const isGetOrHead = methodUpper === 'GET' || methodUpper === 'HEAD' || methodUpper === 'SUBSCRIBE' // Resolve path params (replace :param with values) // Sort by key length descending to avoid :id replacing inside :id2 let resolvedPath = path if (params && typeof params === 'object') { const entries = Object.entries(params).sort( ([a], [b]) => b.length - a.length, ) for (const [key, value] of entries) { if (key === '*') { resolvedPath = resolvedPath.split('*').join(String(value)) } else { resolvedPath = resolvedPath.split(`:${key}`).join(String(value)) } } } const queryString = buildQueryString(query) // Support absolute URLs — skip baseUrl concatenation const isAbsoluteUrl = /^https?:\/\//i.test(resolvedPath) const url = isAbsoluteUrl ? resolvedPath + queryString : baseUrl + resolvedPath + queryString let headers = processHeaders(configHeaders, resolvedPath, { method: methodUpper, signal, }) headers = { ...headers, ...processHeaders(optionHeaders, resolvedPath, { method: methodUpper, signal, }), } let fetchInit: RequestInit = { method: methodUpper, headers, signal, ...restInit, } // Apply onRequest hooks (first pass, before body serialization) if (onRequest) { const hooks = Array.isArray(onRequest) ? onRequest : [onRequest] for (const hook of hooks) { const temp = await hook(resolvedPath, fetchInit) if (typeof temp === 'object') { fetchInit = { ...fetchInit, ...temp, headers: { ...fetchInit.headers, ...processHeaders(temp.headers, resolvedPath, fetchInit), }, } } } } // Ensure GET/HEAD has no body before serialization if (isGetOrHead) delete fetchInit.body // Serialize body if (!isGetOrHead && body !== undefined) { fetchInit.body = body await serializeBody({ body, fetchInit, isGetOrHead }) } if (isGetOrHead) { delete fetchInit.body } // Add x-spiceflow-agent header ;(fetchInit.headers as Record)['x-spiceflow-agent'] = 'spiceflow-client' // Apply onRequest hooks (second pass, after body serialization — matches proxy client behavior) if (onRequest) { const hooks = Array.isArray(onRequest) ? onRequest : [onRequest] for (const hook of hooks) { const temp = await hook(resolvedPath, fetchInit) if (typeof temp === 'object') { fetchInit = { ...fetchInit, ...temp, headers: { ...fetchInit.headers, ...processHeaders(temp.headers, resolvedPath, fetchInit), } as Record, } } } } // Execute request with retries const executeRequest = () => executeWithRetries({ url, fetchInit, fetcher: fetcher || fetch, instance, state: (config as any).state, retries, }) const response = await executeRequest() // Process onResponse hooks if (onResponse) { const hooks = Array.isArray(onResponse) ? onResponse : [onResponse] for (const hook of hooks) { await hook(response.clone()) } } // Parse response const { data, error } = await parseResponseData({ response, executeRequest, retries, }) if (error) { error.response = response return error } return data } return spiceflowFetch as any }