diff --git a/package.json b/package.json index 276fda4..19819ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.12.3", + "version": "0.13.0", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..6de6a72 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,122 @@ +import {clean} from './objects'; +import {PromiseProgress} from './promise-progress.ts'; + +export type DecodedResponse = Response & {data?: T} + +export type HttpInterceptor = (response: Response, next: () => void) => void; + +export type HttpRequestOptions = { + url?: string; + fragment?: string; + query?: {key: string, value: string}[] | {[key: string]: string}; + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: any; + headers?: {[key: string | symbol]: string | null | undefined}; + [key: string]: any; +} + +export type HttpDefaults = { + headers?: {[key: string | symbol]: string | null | undefined}; + interceptors?: HttpInterceptor[]; + url?: string; +} + +export class Http { + private static interceptors: {[key: string]: HttpInterceptor} = {}; + + static headers: {[key: string]: string | null | undefined} = {}; + + private interceptors: {[key: string]: HttpInterceptor} = {} + + headers: {[key: string]: string | null | undefined} = {} + url!: string | null; + + constructor(defaults: HttpDefaults = {}) { + this.url = defaults.url ?? null; + this.headers = defaults.headers || {}; + if(defaults.interceptors) { + defaults.interceptors.forEach(i => Http.addInterceptor(i)); + } + } + + static addInterceptor(fn: HttpInterceptor): () => void { + const key = Object.keys(Http.interceptors).length.toString(); + Http.interceptors[key] = fn; + return () => { Http.interceptors[key] = null; } + } + + addInterceptor(fn: HttpInterceptor): () => void { + const key = Object.keys(this.interceptors).length.toString(); + this.interceptors[key] = fn; + return () => { this.interceptors[key] = null; } + } + + request(opts: HttpRequestOptions = {}): PromiseProgress> { + if(!this.url && !opts.url) throw new Error('URL needs to be set'); + let url = (opts.url?.startsWith('http') ? opts.url : (this.url || '') + (opts.url || '')).replace(/([^:]\/)\/+/g, '$1'); + if(opts.fragment) url.includes('#') ? url.replace(/#.*(\?|\n)/g, (match, arg1) => `#${opts.fragment}${arg1}`) : url += '#' + opts.fragment; + if(opts.query) { + const q = Array.isArray(opts.query) ? opts.query : + Object.keys(opts.query).map(k => ({key: k, value: (opts.query)[k]})) + url += (url.includes('?') ? '&' : '?') + q.map(q => `${q.key}=${q.value}`).join('&'); + } + + // Prep headers + const headers = clean({ + 'Content-Type': !opts.body ? undefined : (opts.body instanceof FormData ? 'multipart/form-data' : 'application/json'), + ...Http.headers, + ...this.headers, + ...opts.headers + }); + + if(typeof opts.body == 'object' && opts.body != null && headers['Content-Type'] == 'application/json') + opts.body = JSON.stringify(opts.json); + + // Send request + return new PromiseProgress((res, rej, prog) => { + fetch(url, { + headers, + method: opts.method || (opts.body ? 'POST' : 'GET'), + body: opts.body + }).then(async (resp: DecodedResponse) => { + console.log('done!'); + for(let fn of [...Object.values(Http.interceptors), ...Object.values(this.interceptors)]) { + await new Promise(res => fn(resp, () => res())); + } + + if(!resp.ok) rej(resp); + + const contentLength = resp.headers.get('Content-Length'); + const total = contentLength ? parseInt(contentLength, 10) : 0; + let loaded = 0; + + const reader = resp.body?.getReader(); + const stream = new ReadableStream({ + start(controller) { + function push() { + reader?.read().then(({done, value}) => { + if(done) return controller.close(); + loaded += value.byteLength; + prog(loaded / total); + controller.enqueue(value); + push(); + }).catch(error => { + controller.error(error); + }); + } + push(); + } + }); + + const data = new Response(stream); + + const content = resp.headers.get('Content-Type')?.toLowerCase(); + if(content?.includes('json')) resp.data = await data.json(); + else if(content?.includes('text')) resp.data = await data.text(); + else if(content?.includes('form')) resp.data = await data.formData(); + else if(content?.includes('application')) resp.data = await data.blob(); + res(resp); + }) + }); + } +} diff --git a/src/index.ts b/src/index.ts index c78143c..4dfab56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,4 +10,4 @@ export * from './objects'; export * from './promise-progress'; export * from './string'; export * from './time'; -export * from './xhr'; +export * from './http.ts'; diff --git a/src/promise-progress.ts b/src/promise-progress.ts index d3a075a..1f41e7d 100644 --- a/src/promise-progress.ts +++ b/src/promise-progress.ts @@ -15,12 +15,38 @@ export class PromiseProgress extends Promise { super((resolve, reject) => executor( (value: T) => resolve(value), (reason: any) => reject(reason), - (progress: number) => this.progress = progress + (progress: number) => this.progress = progress, )); } + static from(promise: Promise): PromiseProgress { + if(promise instanceof PromiseProgress) return promise; + return new PromiseProgress((res, rej) => promise + .then((...args) => res(...args)) + .catch((...args) => rej(...args))); + } + + private from(promise: Promise): PromiseProgress { + const newPromise = PromiseProgress.from(promise); + this.onProgress(p => newPromise.progress = p); + return newPromise; + } + onProgress(callback: ProgressCallback) { this.listeners.push(callback); return this; } + + then(res?: (v: T) => any, rej?: (err: any) => any): PromiseProgress { + const resp = super.then(res, rej); + return this.from(resp); + } + + catch(rej?: (err: any) => any): PromiseProgress { + return this.from(super.catch(rej)); + } + + finally(res?: () => any): PromiseProgress { + return this.from(super.finally(res)); + } } diff --git a/src/xhr.ts b/src/xhr.ts deleted file mode 100644 index e4d0df4..0000000 --- a/src/xhr.ts +++ /dev/null @@ -1,90 +0,0 @@ -import {clean} from './objects'; - -export type Interceptor = (request: Response, next: () => void) => void; - -export type RequestOptions = { - url?: string; - fragment?: string; - query?: {key: string, value: string}[] | {[key: string]: string}; - method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; - body?: any; - headers?: {[key: string | symbol]: string | null | undefined}; - skipConverting?: boolean; - [key: string]: any; -} - -export type XhrOptions = { - headers?: {[key: string | symbol]: string | null | undefined}; - interceptors?: Interceptor[]; - url?: string; -} - -export class XHR { - private static interceptors: {[key: string]: Interceptor} = {}; - - static headers: {[key: string]: string | null | undefined} = {}; - - private interceptors: {[key: string]: Interceptor} = {} - - headers: {[key: string]: string | null | undefined} = {} - - constructor(public readonly opts: XhrOptions = {}) { - this.headers = opts.headers || {}; - if(opts.interceptors) { - opts.interceptors.forEach(i => XHR.addInterceptor(i)); - } - } - - static addInterceptor(fn: Interceptor): () => void { - const key = Object.keys(XHR.interceptors).length.toString(); - XHR.interceptors[key] = fn; - return () => { XHR.interceptors[key] = null; } - } - - addInterceptor(fn: Interceptor): () => void { - const key = Object.keys(this.interceptors).length.toString(); - this.interceptors[key] = fn; - return () => { this.interceptors[key] = null; } - } - - async request(opts: RequestOptions = {}): Promise { - if(!this.opts.url && !opts.url) throw new Error('URL needs to be set'); - let url = (opts.url?.startsWith('http') ? opts.url : (this.opts.url || '') + (opts.url || '')).replace(/([^:]\/)\/+/g, '$1'); - if(opts.fragment) url.includes('#') ? url.replace(/#.*(\?|\n)/g, (match, arg1) => `#${opts.fragment}${arg1}`) : url += '#' + opts.fragment; - if(opts.query) { - const q = Array.isArray(opts.query) ? opts.query : - Object.keys(opts.query).map(k => ({key: k, value: (opts.query)[k]})) - url += (url.includes('?') ? '&' : '?') + q.map(q => `${q.key}=${q.value}`).join('&'); - } - - // Prep headers - const headers = clean({ - 'Content-Type': (opts.body && !(opts.body instanceof FormData)) ? 'application/json' : undefined, - ...XHR.headers, - ...this.headers, - ...opts.headers - }); - - // Send request - return fetch(url, { - headers, - method: opts.method || (opts.body ? 'POST' : 'GET'), - body: (headers['Content-Type']?.startsWith('application/json') && opts.body) ? JSON.stringify(opts.body) : opts.body - }).then(async resp => { - for(let fn of [...Object.values(XHR.interceptors), ...Object.values(this.interceptors)]) { - await new Promise(res => fn(resp, () => res())); - } - - const decode = async () => { - if(!opts.skipConverting && resp.headers.get('Content-Type')?.startsWith('application/json')) return await resp.json(); - if(!opts.skipConverting && resp.headers.get('Content-Type')?.startsWith('text/plain')) return await resp.text(); - return resp; - } - - const payload = await decode(); - if(resp.ok) return payload; - throw Object.assign(new Error(typeof payload == 'string' ? payload : resp.statusText), - typeof payload == 'object' ? payload : {}); - }); - } -}