import { ClientError, GraphQLClient, RequestDocument, Variables } from 'graphql-request' import { RequestInit } from 'graphql-request/dist/types.dom' import { decodeJwt } from 'jose' import { createAuth } from './create-auth' import { ApiCredentials, ClientOptions, Logger } from './types' export const createCoreClient = ( options: ClientOptions, { _logger, _onAuthStateChange, }: { _logger: Logger; _onAuthStateChange?: ReturnType['_onAuthStateChange'] }, ) => { let accessToken = '' const { apiKey, apiCredentials, getCredentials, onError, retries = 3 } = options if (typeof retries !== 'number') { throw new Error('retries must a be a number') } if (onError && typeof onError !== 'function') { throw new Error('onError must be a function') } const _errorHandler = onError _logger('Initializing @vendia/client...') _logger(`'apiKey' was ${apiKey ? '' : 'NOT '}provided.`) const _createGraphQLClient = () => { let sdkHeader = '' try { //@ts-ignore PACKAGE_VERSION injected at build-time by generateClient.ts sdkHeader = `@vendia/client@${PACKAGE_VERSION}` } catch (e) { //If PACKAGE_VERSION was not injected, send a default sdkHeader = `@vendia/client@unknown` } const opts: any = { headers: { 'Content-Type': 'application/json', 'x-vendia-sdk': sdkHeader, }, } if (options.apiKey) { // Auth V1 uses x-api-key header, V2 uses Authorization header opts.headers['x-api-key'] = options.apiKey opts.headers.Authorization = options.apiKey } if (options.fetch) { opts.fetch = options.fetch } return new GraphQLClient(options.apiUrl, opts) } type SdkFunctionWrapper = ( action: (requestHeaders?: Record) => Promise, operationName: string, // Retry attempt, defaults to 0 attempt?: number, ) => Promise function getRetryDelay(attempt: number) { // 1000, 2000, 4000 etc, up to maximum of 30 seconds return Math.min(Math.pow(2, attempt) * 1000, 30 * 1000) } const _requestWrapper: SdkFunctionWrapper = async (action, operationName: string, attempt = 0) => { try { // Debug timer const startTime = Date.now() // Get credentials before each request (most auth libs handle caching or user can cache themselves) const requestHeaders: Record = {} if (apiCredentials) { accessToken = await getAliveAccessToken({ apiCredentials, accessToken }) requestHeaders.Authorization = `Bearer ${accessToken}` } if (typeof getCredentials === 'function') { const credentials = await getCredentials() if (credentials?.token) { requestHeaders.Authorization = `Bearer ${credentials.token}` } if (credentials.apiKey) { requestHeaders['x-api-key'] = credentials.apiKey requestHeaders.Authorization = credentials.apiKey } _logger(`${operationName} getCredentials duration (ms)`, Date.now() - startTime) } const result = await action(requestHeaders) _logger(`${operationName} request duration (ms)`, Date.now() - startTime) return result } catch (error: any) { // RETRY LOGIC _logger('Request error, status:', error?.response?.status) if (attempt < retries) { const delay = getRetryDelay(attempt) _logger(`Retrying ${operationName} (attempt #${attempt + 1}, ${delay}ms delay)`) await new Promise((resolve) => setTimeout(resolve, delay)) return _requestWrapper(action, operationName, attempt + 1) } if (_errorHandler) { _logger(`Passing error to user-provided error handler`) _errorHandler(error as ClientError | Error) } return Promise.reject(error) } } const _gqlClient = _createGraphQLClient() // Copy the type signature of the GraphQLClient.request method and wrap it so it works // with getCredentials, errorHandler, etc. const request = ( document: RequestDocument, variables?: V, requestHeaders?: RequestInit['headers'], ): Promise => { return _requestWrapper((additionalHeaders) => { const mergedHeaders = { ...requestHeaders, ...additionalHeaders } return _gqlClient.request(document, variables, mergedHeaders) }, 'request') } return { // Internal _gqlClient, _requestWrapper, // Public request, } } export interface GetClientCredentialsAccessTokenParams { apiCredentials: ApiCredentials accessToken: string } async function getAliveAccessToken({ apiCredentials, accessToken, }: GetClientCredentialsAccessTokenParams): Promise { const aliveAccessToken = isTokenExpired(accessToken) ? await refreshAccessToken({ apiCredentials }) : accessToken return aliveAccessToken } export const isTokenExpired = (token: string) => { try { if (!token?.length) { return true } const decodedJwt = decodeJwt(token) if (!decodedJwt?.exp) return true // Return true if the token is expired or about to expire (in ~5s) const tokenExpiration = decodedJwt.exp * 1000 return tokenExpiration < Date.now() - 5000 } catch (e) { console.warn('Unable to parse JWT', e) return true } } export interface RefreshAccessTokenParams { apiCredentials: ApiCredentials } async function refreshAccessToken({ apiCredentials }: RefreshAccessTokenParams) { const jsonBody = { grant_type: 'client_credentials', client_id: apiCredentials.clientId, client_secret: apiCredentials.clientSecret, } const urlEncodedBody = new URLSearchParams(Object.entries(jsonBody)).toString() const response = await fetch(VENDIA_API_CREDENTIALS_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: urlEncodedBody, }) const accessToken = await response.json() return (accessToken as any).access_token } const VENDIA_API_CREDENTIALS_TOKEN_URL = process.env.VENDIA_API_CREDENTIALS_TOKEN_URL || 'https://auth.share.vendia.com/token'