diff --git a/.gitignore b/.gitignore index b81a736..251a60f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __snapshots__ *.tsbuildinfo *.tgz *.log +.turbo \ No newline at end of file diff --git a/packages/plexus-api/src/api.ts b/packages/plexus-api/src/api.ts index 522c39d..0f78846 100644 --- a/packages/plexus-api/src/api.ts +++ b/packages/plexus-api/src/api.ts @@ -4,11 +4,12 @@ import { ApiMethod, PlexusApiConfig, PlexusApiReq, - PlexusApiRes, PlexusApiSendOptions, PlexusApiFetchOptions, + PlexusApiInstanceConfig, } from './types' import { PlexusError } from '@plexusjs/utils' +import { ApiRequest } from './request' // let's get Blob from Node.js or browser let Blob if (typeof window === 'undefined') { @@ -26,13 +27,6 @@ globalThis.Blob = Blob export type PlexusApi = ApiInstance const AuthTypes = ['bearer', 'basic', 'jwt'] as const -// type HeaderCache> = -// | [ -// CacheValue | Promise | undefined, -// (() => CacheValue | Promise | undefined) | undefined -// ] -// | [] - /** * An API instance is used to make requests to a server. Interact with this by using `api()` */ @@ -43,18 +37,21 @@ export class ApiInstance { | Record | Promise> = () => ({}) + private requestMap: Map = new Map() + private disabled = false // ":": [() => void, ...] private waitingQueues: Map Promise)[]> = new Map() constructor( baseURL: string = '', - config: PlexusApiConfig = { defaultOptions: {} } + config: PlexusApiInstanceConfig = { defaultOptions: {} } ) { this._internalStore = { options: config.defaultOptions ?? {}, optionsInit: { ...config.defaultOptions }, timeout: config.timeout || undefined, abortOnTimeout: config.abortOnTimeout ?? true, + retry: config.retry || undefined, baseURL: baseURL.endsWith('/') && baseURL.length > 1 ? baseURL.substring(0, baseURL.length - 1) @@ -65,6 +62,8 @@ export class ApiInstance { silentFail: config.silentFail ?? false, onResponse: config.onResponse, } + + // if we don't have fetch, set noFetch to true try { fetch } catch (e) { @@ -76,150 +75,9 @@ export class ApiInstance { } config.headers && this.setHeaders(config.headers) } - /** - * Send a request to the server - * @param path - * @param options - */ - private async makeRequest( - path: string, - options: PlexusApiSendOptions - ): Promise> { - // if we don't have fetch, return a blank response object - if (this._internalStore.noFetch) - return ApiInstance.createEmptyRes() - - const pureHeaders = await this.headerGetter() - - const headers = { - ...pureHeaders, - ...(options.headers ?? {}), - } - - if (!headers['Content-Type']) { - if (options.body !== undefined) { - headers['Content-Type'] = 'application/json' - } else { - headers['Content-Type'] = 'text/html' - } - } - // init values used later - let timedOut = false - let res: Response | undefined - try { - // build out the URI - const matches = path.match(/^http(s)?/g) - const uri = - matches && matches?.length > 0 - ? path - : `${this._internalStore.baseURL}${ - path.startsWith('/') || path?.length === 0 ? path : `/${path}` - }` - - const controller = new AbortController() - const requestObject = { - ...this._internalStore.options, - ...options, - headers, - signal: controller.signal, - } - // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response - if (this._internalStore.timeout) { - let to: any - const timeout = new Promise((resolve, reject) => { - to = setTimeout(() => { - timedOut = true - resolve() - }, this._internalStore.timeout) - }) - const request = new Promise((resolve, reject) => { - fetch(uri, requestObject) - .then((response) => { - clearTimeout(to) - resolve(response) - }) - .catch(reject) - }) - - // race the timeout and the request - const raceResult = await Promise.race([timeout, request]) - - if (raceResult) { - res = raceResult - } else { - if (this._internalStore.abortOnTimeout) controller.abort() - - // if we're throwing, throw an error - if (this._internalStore.throws) - throw new PlexusError('Request timed out', { type: 'api' }) - // a 504 response status means the programmatic timeout was surpassed - return ApiInstance.createEmptyRes( - timedOut ? 504 : res?.status ?? 513 - ) - } - } - // if we don't have a timeout set, just try to fetch - else { - res = await fetch(uri, requestObject) - } - } catch (e) { - // if silentFail is enabled, don't throw the error; Otherwise, throw an error - if (!this._internalStore.silentFail) { - throw e - } - } - let data: ResponseDataType - let rawData: string - let blob: Blob - // we never got a response - if (res === undefined) { - return ApiInstance.createEmptyRes(500) - } - - const hasCookie = (cName: string): boolean => { - return res?.headers?.get('set-cookie')?.includes(cName) ?? false - } - const ok = res.status > 199 && res.status < 300 - - // if we got a response, parse it and return it - if (res.status >= 200 && res.status < 600) { - const text = await res.text() - let parsed: ResponseDataType = undefined as any - try { - parsed = JSON.parse(text || '{}') as ResponseDataType - } catch (e) {} - data = parsed ?? ({} as ResponseDataType) - rawData = text - blob = new Blob([text], { type: 'text/plain' }) - - const pResponse = { - status: res.status, - response: res, - rawData, - blob, - ok, - data, - hasCookie, - } - // if(this._internalStore.onResponse) this._internalStore.onResponse(req, pResponse) - if (this._internalStore.throws && !ok) { - throw pResponse - } - return pResponse - } - // if we got a response, but it's not in the 200~600 range, return it - return { - status: res.status, - response: res, - rawData: '', - ok, - data: {} as ResponseDataType, - hasCookie, - } - } /** - * Do some pre-send stuff + * Send a request to the api instance * @param path * @param options */ @@ -227,9 +85,22 @@ export class ApiInstance { path: string, options: PlexusApiSendOptions ) { - if (this.disabled) return ApiInstance.createEmptyRes(0) + if (this.disabled) return ApiRequest.createEmptyRes(0) // this.addToQueue(`${this.genKey('GET', path)}`, () => {}) - const res = await this.makeRequest(path, options) + let request: ApiRequest + if (!this.requestMap.has(path)) { + request = new ApiRequest(this, path, { defaultOptions: options }) + this.requestMap.set(path, request) + } else { + request = this.requestMap.get(path) as ApiRequest + } + + // if we don't have fetch, return a blank response object + + const res = this.config.noFetch + ? ApiRequest.createEmptyRes() + : await request.send(path, options) + const headers = await this.headerGetter() this._internalStore.onResponse?.( { @@ -541,26 +412,20 @@ export class ApiInstance { get config() { return Object.freeze( deepClone({ - ...this._internalStore.options, - headers: ApiInstance.parseHeaders(this._headers), + ...this._internalStore, + + options: { + ...this._internalStore.options, + headers: ApiInstance.parseHeaders(this._headers), + } as { + headers: Record + } & RequestInit, }) - ) as { - headers: Record - } & RequestInit + ) } enabled(status: boolean = true) { this.disabled = !status } - private static createEmptyRes(status: number = 408) { - return { - status, - response: {} as Response, - rawData: '', - data: {} as ResponseDataType, - ok: status > 199 && status < 300, - hasCookie: (name: string) => false, - } - } } export function api( diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts new file mode 100644 index 0000000..a5114f6 --- /dev/null +++ b/packages/plexus-api/src/request.ts @@ -0,0 +1,226 @@ +import { PlexusError } from '@plexusjs/utils' +import { ApiInstance } from './api' +import { + PlexusApiConfig, + PlexusApiReq, + PlexusApiRes, + PlexusApiSendOptions, +} from './types' +import { uuid } from './utils' + +export class ApiRequest { + private attempts = 0 + private controllerMap: Map = new Map() + constructor( + public api: ApiInstance, + public path: string, + public config: PlexusApiConfig + ) {} + + public getRequestSchema( + method: PlexusApiReq['method'], + payload?: { + body?: BodyType + path?: string + } + ) { + const body = payload?.body ?? ({} as BodyType) + return { + path: this.path, + baseURL: this.api.config.baseURL, + options: this.api.config.options, + headers: this.config.headers, + body, + method, + } as PlexusApiReq + } + + /** + * Retry a request + * @param path The path to send the request to + * @param options The options to send with the request + * @returns undefined if the request can't be retried, otherwise a pending response + */ + private async retry( + path: string, + options: PlexusApiSendOptions + ) { + if (!!this.config.retry) { + console.log('retrying', this.attempts, this.config.retry) + if (this.attempts < this.config.retry) { + return false + this.attempts++ + this.config.onRetry?.( + this.attempts, + this.getRequestSchema(options.method, { + body: options.body, + }) + ) + return await this.send(path, options) + } + } + } + /** + * Send a request to the server + * @param path + * @param options + */ + async send( + path: string, + options: PlexusApiSendOptions + ): Promise> { + const requestId = uuid() + const instanceHeaders = this.api.headers + + const headers = { + ...instanceHeaders, + ...(options.headers ?? {}), + } + + if (!headers['Content-Type']) { + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json' + } else { + headers['Content-Type'] = 'text/html' + } + } + // init values used later + let timedOut = false + let res: Response | undefined + try { + // build out the URI + const matches = path.match(/^http(s)?/g) + const uri = + matches && matches?.length > 0 + ? path + : `${this.api.config.baseURL}${ + path.startsWith('/') || path?.length === 0 ? path : `/${path}` + }` + + // create a new abort controller and add it to the controller map + const controller = new AbortController() + const requestObject = { + ...this.api.config.options, + ...options, + headers, + signal: controller.signal, + } + this.controllerMap.set(requestId, controller) + + // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response + if (this.config.timeout) { + let to: any + const timeout = new Promise((resolve, reject) => { + to = setTimeout(() => { + timedOut = true + resolve() + }, this.config.timeout) + }) + const request = new Promise((resolve, reject) => { + fetch(uri, requestObject) + .then((response) => { + clearTimeout(to) + resolve(response) + }) + .catch(reject) + }) + + // race the timeout and the request + const raceResult = await Promise.race([timeout, request]) + + if (raceResult) { + res = raceResult + } else { + // abort the request + if (this.config.abortOnTimeout) controller.abort() + + // if retry returns something (which means it's retrying), return it + const retrying = await this.retry(path, options) + if (!!this.config.retry && retrying) return retrying + + // if we're throwing, throw an error + if (this.config.throws) + throw new PlexusError('Request timed out', { type: 'api' }) + // a 504 response status means the programmatic timeout was surpassed + return ApiRequest.createEmptyRes( + timedOut ? 504 : res?.status ?? 513 + ) + } + } + // if we don't have a timeout set, just try to fetch + else { + res = await fetch(uri, requestObject) + } + } catch (e) { + // if retry returns something (which means it's retrying), return it + const retrying = await this.retry(path, options) + if (!!this.config.retry && retrying) return retrying + // if silentFail is enabled, don't throw the error; Otherwise, throw an error + if (!this.config.throws) { + throw e + } + } + // we're successful, reset the retry counter + this.attempts = 0 + + let data: ResponseDataType + let rawData: string + let blob: Blob + // we never got a response + if (res === undefined) { + return ApiRequest.createEmptyRes(500) + } + + const hasCookie = (cName: string): boolean => { + return res?.headers?.get('set-cookie')?.includes(cName) ?? false + } + const ok = res.status > 199 && res.status < 300 + + // if we got a response, parse it and return it + if (res.status >= 200 && res.status < 600) { + const text = await res.text() + let parsed: ResponseDataType = undefined as any + try { + parsed = JSON.parse(text || '{}') as ResponseDataType + } catch (e) {} + data = parsed ?? ({} as ResponseDataType) + rawData = text + blob = new Blob([text], { type: 'text/plain' }) + + const pResponse = { + status: res.status, + response: res, + rawData, + blob, + ok, + data, + hasCookie, + } + // if(this._internalStore.onResponse) this._internalStore.onResponse(req, pResponse) + if (this.config.throws && !ok) { + throw pResponse + } + return pResponse + } + // if we got a response, but it's not in the 200~600 range, return it + return { + status: res.status, + response: res, + rawData: '', + ok, + data: {} as ResponseDataType, + hasCookie, + } + } + + static createEmptyRes(status: number = 408) { + return { + status, + response: {} as Response, + rawData: '', + data: {} as ResponseDataType, + ok: status > 199 && status < 300, + hasCookie: (name: string) => false, + } + } +} diff --git a/packages/plexus-api/src/types.ts b/packages/plexus-api/src/types.ts index 973533b..cfbb64d 100644 --- a/packages/plexus-api/src/types.ts +++ b/packages/plexus-api/src/types.ts @@ -10,10 +10,9 @@ export interface PlexusApiRes { export interface PlexusApiConfig { defaultOptions?: PlexusApiOptions timeout?: number + retry?: number + onRetry?: (currentRetry: number, req: PlexusApiReq) => void abortOnTimeout?: boolean - // Deprecated - silentFail?: boolean - throws?: boolean onResponse?: (req: PlexusApiReq, res: PlexusApiRes) => void headers?: @@ -21,10 +20,14 @@ export interface PlexusApiConfig { | (() => Record) | (() => Promise>) } +export interface PlexusApiInstanceConfig extends PlexusApiConfig { + // Deprecated + silentFail?: boolean +} export interface PlexusApiReq { path: string baseURL: string - fullURL: string + // fullURL: string method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH' headers: Record body: BodyType @@ -55,6 +58,7 @@ export interface ApiStore { options: PlexusApiOptions optionsInit: PlexusApiOptions timeout: number | undefined + retry: number | undefined abortOnTimeout: boolean baseURL: string noFetch: boolean diff --git a/packages/plexus-api/src/utils.ts b/packages/plexus-api/src/utils.ts new file mode 100644 index 0000000..18aa777 --- /dev/null +++ b/packages/plexus-api/src/utils.ts @@ -0,0 +1,7 @@ +export const uuid = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 33c6ab0..3da2d60 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -22,8 +22,8 @@ describe('Testing Api Function', () => { console.log(myApi.config) // console.log(myApi.config) expect(myApi.config).toBeDefined() - expect(myApi.config.headers).toBeDefined() - expect(myApi.config.headers['custom']).toBe('header') + expect(myApi.config.options.headers).toBeDefined() + expect(myApi.config.options.headers['custom']).toBe('header') const res = await myApi.get('https://google.com') expect(res?.status).toBeGreaterThan(0) @@ -43,8 +43,8 @@ describe('Testing Api Function', () => { }) // console.log(myApi.config) expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') + expect(apiUsingOnResponse.headers).toBeDefined() + expect(apiUsingOnResponse.headers['custom']).toBe('header') const res = await apiUsingOnResponse.get('https://google.com') expect(res?.status).toBeGreaterThan(0) @@ -60,10 +60,6 @@ describe('Testing Api Function', () => { custom: 'header', }, }) - // console.log(myApi.config) - expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') await expect( apiUsingOnResponse.post('https://google.com/this/url/doesnt/exist') @@ -81,10 +77,6 @@ describe('Testing Api Function', () => { custom: 'header', }, }) - // console.log(myApi.config) - expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') await expect( apiUsingOnResponse.post('https://google.com/this/url/doesnt/exist') @@ -128,7 +120,46 @@ describe('Testing Api Function', () => { expect(errorOccurred).toBe(false) }) -}, 10000) + test('Does retry work', async () => { + // const value = state(1) + // should retry 3 times + let loopCount = 0 + const apiUsingOnResponse = api('', { + timeout: 100, + throws: true, + retry: 3, + abortOnTimeout: true, + onRetry(iteration) { + console.log('retrying', iteration) + loopCount = iteration + }, + }) + + try { + await apiUsingOnResponse.post('http://httpstat.us/526?sleep=200') + } catch (error) { + console.log(error) + } + // expect(errorOccurred).toBe(3) + expect(loopCount).toBe(3) + + // Check if a second error is thrown + // errorOccurred = false + + try { + await apiUsingOnResponse.post('http://httpstat.us/500') + } catch (error) { + console.log(error) + if (error instanceof PlexusError) { + // if it's a PlexusError, it means this is the timeout error + return + } + // errorOccurred = true + } + + expect(loopCount).toBe(3) + }) +}) describe("Test the API's baseURL capabilities", () => { const myApi2 = api('https://google.com').setHeaders({ 'Content-Type': 'application/json', @@ -136,7 +167,7 @@ describe("Test the API's baseURL capabilities", () => { test('Can make a request to a sub-path', async () => { const res = await myApi2.post('maps') - expect(myApi2.config.headers['Content-Type']).toBe('application/json') + expect(myApi2.headers['Content-Type']).toBe('application/json') // console.log(JSON.stringify(res, null, 2)) expect(res?.status).toBeGreaterThan(0) }) @@ -154,7 +185,7 @@ describe("Test the API's baseURL capabilities", () => { const res = await myApi2.post('maps') - expect(myApi2.config.headers['X-Test']).toBe(intendedValue) + expect(myApi2.headers['X-Test']).toBe(intendedValue) // console.log(JSON.stringify(res, null, 2)) expect(res?.status).toBeGreaterThan(0) }) diff --git a/tests/edgecases.test.tsx b/tests/edgecases.test.tsx index d15108a..3f2ec3f 100644 --- a/tests/edgecases.test.tsx +++ b/tests/edgecases.test.tsx @@ -89,7 +89,7 @@ describe('Collection Relations', () => { ) test('Batching race condition with selectors', () => { - batch(() => { }) + batch(() => {}) }) })