UNPKG

laravel-precognition

Version:
240 lines (239 loc) 7.64 kB
import { merge } from 'es-toolkit/compat'; import { hasFiles } from './form.js'; import { HttpResponseError } from './http/errors.js'; import { fetchHttpClient } from './http/fetchClient.js'; /** * The configured HTTP client. */ let httpClient = fetchHttpClient; /** * The configured base URL. */ let baseURL = undefined; /** * The configured default timeout. */ let timeout = undefined; /** * The configured credentials mode. */ let credentials = 'same-origin'; /** * The request fingerprint resolver. */ let requestFingerprintResolver = (config) => `${config.method}:${config.baseURL ?? baseURL ?? ''}${config.url}`; /** * The precognition success resolver. */ let successResolver = (response) => response.status === 204 && response.headers['precognition-success'] === 'true'; /** * The abort controller cache. */ const abortControllers = {}; /** * The precognitive HTTP client instance. */ export const client = { get: (url, data = {}, config = {}) => request(mergeConfig('get', url, data, config)), post: (url, data = {}, config = {}) => request(mergeConfig('post', url, data, config)), patch: (url, data = {}, config = {}) => request(mergeConfig('patch', url, data, config)), put: (url, data = {}, config = {}) => request(mergeConfig('put', url, data, config)), delete: (url, data = {}, config = {}) => request(mergeConfig('delete', url, data, config)), useHttpClient(newHttpClient) { httpClient = newHttpClient; return client; }, withBaseURL(url) { baseURL = url; return client; }, withTimeout(duration) { timeout = duration; return client; }, withCredentials(value) { credentials = typeof value === 'string' ? value : (value ? 'include' : 'omit'); return client; }, fingerprintRequestsUsing(callback) { requestFingerprintResolver = callback === null ? () => null : callback; return client; }, determineSuccessUsing(callback) { successResolver = callback; return client; }, withXsrfCookieName(name) { fetchHttpClient.setXsrfCookieName(name); return client; }, withXsrfHeaderName(name) { fetchHttpClient.setXsrfHeaderName(name); return client; }, }; /** * Merge the client specified arguments with the provided configuration. */ const mergeConfig = (method, url, data, config) => ({ url, method, ...config, ...(['get', 'delete'].includes(method) ? { params: merge({}, data, config?.params), } : { data: merge({}, data, config?.data), }), }); /** * Send and handle a new request. */ const request = (userConfig = {}) => { const config = [ resolveConfig, abortMatchingRequests, refreshAbortController, ].reduce((config, callback) => callback(config), userConfig); if ((config.onBefore ?? (() => true))() === false) { return Promise.resolve(null); } (config.onStart ?? (() => null))(); return httpClient.request({ method: config.method, url: config.url, baseURL: config.baseURL ?? baseURL, data: config.data, params: config.params, headers: config.headers, signal: config.signal, timeout: config.timeout, credentials, }).then(async (response) => { if (config.precognitive) { validatePrecognitionResponse(response); } const status = response.status; let payload = response; if (config.precognitive && config.onPrecognitionSuccess && successResolver(response)) { payload = await Promise.resolve(config.onPrecognitionSuccess(response) ?? payload); } if (config.onSuccess && isSuccess(status)) { payload = await Promise.resolve(config.onSuccess(payload) ?? payload); } const statusHandler = resolveStatusHandler(config, status) ?? ((response) => response); return statusHandler(payload) ?? payload; }, (error) => { if (isNotServerGeneratedError(error)) { return Promise.reject(error); } const httpError = error; if (config.precognitive) { validatePrecognitionResponse(httpError.response); } const statusHandler = resolveStatusHandler(config, httpError.response.status) ?? ((_, error) => Promise.reject(error)); return statusHandler(httpError.response, httpError); }).finally(config.onFinish ?? (() => null)); }; /** * Resolve the configuration. */ const resolveConfig = (config) => { const only = config.only ?? config.validate; return { ...config, timeout: config.timeout ?? timeout, precognitive: config.precognitive !== false, fingerprint: typeof config.fingerprint === 'undefined' ? requestFingerprintResolver(config, httpClient) : config.fingerprint, headers: { ...config.headers, 'Accept': 'application/json', 'Content-Type': resolveContentType(config), ...config.precognitive !== false ? { Precognition: true, } : {}, ...only ? { 'Precognition-Validate-Only': Array.from(only).join(), } : {}, }, }; }; /** * Determine if the status is successful. */ const isSuccess = (status) => status >= 200 && status < 300; /** * Abort an existing request with the same configured fingerprint. */ const abortMatchingRequests = (config) => { if (typeof config.fingerprint !== 'string') { return config; } abortControllers[config.fingerprint]?.abort(); delete abortControllers[config.fingerprint]; return config; }; /** * Create and configure the abort controller for a new request. */ const refreshAbortController = (config) => { if (typeof config.fingerprint !== 'string' || config.signal || !config.precognitive) { return config; } abortControllers[config.fingerprint] = new AbortController; return { ...config, signal: abortControllers[config.fingerprint].signal, }; }; /** * Ensure that the response is a Precognition response. */ const validatePrecognitionResponse = (response) => { if (response.headers?.precognition !== 'true') { throw Error('Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.'); } }; /** * Determine if the error was not triggered by a server response. */ const isNotServerGeneratedError = (error) => { return !(error instanceof HttpResponseError) || typeof error.response?.status !== 'number'; }; /** * Resolve the handler for the given HTTP response status. */ const resolveStatusHandler = (config, code) => ({ 401: config.onUnauthorized, 403: config.onForbidden, 404: config.onNotFound, 409: config.onConflict, 422: config.onValidationError, 423: config.onLocked, }[code]); /** * Resolve the request's "Content-Type" header. */ const resolveContentType = (config) => (config.headers?.['Content-Type'] ?? config.headers?.['Content-type'] ?? config.headers?.['content-type'] ?? (hasFiles(config.data) ? 'multipart/form-data' : 'application/json')); /** * Resolve the url from a potential callback. */ export const resolveUrl = (url) => typeof url === 'string' ? url : url(); /** * Resolve the method from a potential callback. */ export const resolveMethod = (method) => typeof method === 'string' ? method.toLowerCase() : method();