diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 8ba67e05b..f79796d29 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -536,7 +536,6 @@ describe('SessionRecording', () => { $window_id: 'windowId', }, { - transport: 'XHR', method: 'POST', endpoint: '/s/', _noTruncate: true, @@ -575,7 +574,6 @@ describe('SessionRecording', () => { }, { method: 'POST', - transport: 'XHR', endpoint: '/s/', _noTruncate: true, _batchKey: 'recordings', @@ -660,7 +658,6 @@ describe('SessionRecording', () => { }, { method: 'POST', - transport: 'XHR', endpoint: '/s/', _noTruncate: true, _batchKey: 'recordings', @@ -1288,7 +1285,6 @@ describe('SessionRecording', () => { _noTruncate: true, endpoint: '/s/', method: 'POST', - transport: 'XHR', } ) expect(sessionRecording['buffer']).toEqual({ diff --git a/src/__tests__/retry-queue.test.ts b/src/__tests__/retry-queue.test.ts index 83356ffb3..87e77254c 100644 --- a/src/__tests__/retry-queue.test.ts +++ b/src/__tests__/retry-queue.test.ts @@ -14,7 +14,7 @@ const defaultRequestOptions: CaptureOptions = { } describe('RetryQueue', () => { - const onXHRError = jest.fn().mockImplementation(console.error) + const onRequestError = jest.fn().mockImplementation(console.error) const rateLimiter = new RateLimiter() let retryQueue: RetryQueue @@ -26,7 +26,7 @@ describe('RetryQueue', () => { }) beforeEach(() => { - retryQueue = new RetryQueue(onXHRError, rateLimiter) + retryQueue = new RetryQueue(onRequestError, rateLimiter) assignableWindow.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass) assignableWindow.navigator.sendBeacon = jest.fn() @@ -112,7 +112,7 @@ describe('RetryQueue', () => { expect(retryQueue.queue.length).toEqual(0) expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(4) - expect(onXHRError).toHaveBeenCalledTimes(0) + expect(onRequestError).toHaveBeenCalledTimes(0) }) it('does not process event retry requests when events are rate limited', () => { @@ -144,7 +144,7 @@ describe('RetryQueue', () => { // clears queue expect(retryQueue.queue.length).toEqual(0) expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(1) - expect(onXHRError).toHaveBeenCalledTimes(0) + expect(onRequestError).toHaveBeenCalledTimes(0) }) it('does not process recording retry requests when they are rate limited', () => { @@ -176,7 +176,7 @@ describe('RetryQueue', () => { // clears queue expect(retryQueue.queue.length).toEqual(0) expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(2) - expect(onXHRError).toHaveBeenCalledTimes(0) + expect(onRequestError).toHaveBeenCalledTimes(0) }) it('tries to send requests via beacon on unload', () => { @@ -201,12 +201,12 @@ describe('RetryQueue', () => { expect(assignableWindow.navigator.sendBeacon).toHaveBeenCalledTimes(0) }) - it('when you flush the queue onXHRError is passed to xhr', () => { - const xhrSpy = jest.spyOn(SendRequest, 'xhr') + it('when you flush the queue onError is passed to xhr', () => { + const xhrSpy = jest.spyOn(SendRequest, 'request') enqueueRequests() retryQueue.flush() fastForwardTimeAndRunTimer() - expect(xhrSpy).toHaveBeenCalledWith(expect.objectContaining({ onXHRError: onXHRError })) + expect(xhrSpy).toHaveBeenCalledWith(expect.objectContaining({ onError: onRequestError })) }) it('enqueues requests when offline and flushes immediately when online again', () => { @@ -220,7 +220,7 @@ describe('RetryQueue', () => { // requests aren't attempted when we're offline expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(0) // doesn't log that it is offline from the retry queue - expect(onXHRError).toHaveBeenCalledTimes(0) + expect(onRequestError).toHaveBeenCalledTimes(0) // queue stays the same expect(retryQueue.queue.length).toEqual(4) diff --git a/src/__tests__/send-request.test.ts b/src/__tests__/send-request.test.ts index 4435e7714..176fcf3df 100644 --- a/src/__tests__/send-request.test.ts +++ b/src/__tests__/send-request.test.ts @@ -1,20 +1,29 @@ +/* eslint-disable compat/compat */ /// -import { addParamsToURL, encodePostData, xhr } from '../send-request' +import { addParamsToURL, encodePostData, request } from '../send-request' import { assert, boolean, property, uint8Array, VerbosityLevel } from 'fast-check' -import { Compression, PostData, XHROptions, XHRParams } from '../types' +import { Compression, PostData, XHROptions, RequestData, MinimalHTTPResponse } from '../types' import { _isUndefined } from '../utils/type-utils' +import { assignableWindow } from '../utils/globals' + +jest.mock('../utils/request-utils', () => ({ + ...jest.requireActual('../utils/request-utils'), + SUPPORTS_XHR: true, + SUPPORTS_FETCH: true, + SUPPORTS_REQUEST: true, +})) jest.mock('../config', () => ({ DEBUG: false, LIB_VERSION: '1.23.45' })) +const flushPromises = () => new Promise((r) => setTimeout(r, 0)) + describe('send-request', () => { describe('xhr', () => { let mockXHR: XMLHttpRequest - let xhrParams: (overrides?: Partial) => XHRParams - let onXHRError: XHRParams['onXHRError'] - let checkForLimiting: XHRParams['onResponse'] - let xhrOptions: XHRParams['options'] + let createRequestData: (overrides?: Partial) => RequestData + let checkForLimiting: RequestData['onResponse'] beforeEach(() => { mockXHR = { @@ -27,21 +36,21 @@ describe('send-request', () => { status: 502, } as Partial as XMLHttpRequest - onXHRError = jest.fn() checkForLimiting = jest.fn() - xhrOptions = {} - xhrParams = (overrides?: Partial) => { + createRequestData = (overrides?: Partial) => { return { url: 'https://any.posthog-instance.com?ver=1.23.45', data: {}, headers: {}, - options: xhrOptions, callback: () => {}, + options: { + transport: 'XHR', + ...(overrides?.options || {}), + }, retriesPerformedSoFar: undefined, retryQueue: { enqueue: () => {}, - } as Partial as XHRParams['retryQueue'], - onXHRError, + } as Partial as RequestData['retryQueue'], onResponse: checkForLimiting, ...overrides, } @@ -50,56 +59,75 @@ describe('send-request', () => { // ignore TS complaining about us cramming a fake in here // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - window.XMLHttpRequest = jest.fn(() => mockXHR) as unknown as XMLHttpRequest + assignableWindow.XMLHttpRequest = jest.fn(() => mockXHR) as unknown as XMLHttpRequest }) - test('it adds the retry count to the URL', () => { - const retryCount = Math.floor(Math.random() * 100) + 1 // make sure it is never 0 - xhr( - xhrParams({ - retriesPerformedSoFar: retryCount, + test('performs the request with default params', () => { + request( + createRequestData({ + retriesPerformedSoFar: 0, url: 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', }) ) expect(mockXHR.open).toHaveBeenCalledWith( 'GET', - `https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278&retry_count=${retryCount}`, + 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', true ) }) - test('does not add retry count when it is 0', () => { - xhr( - xhrParams({ - retriesPerformedSoFar: 0, + test('it adds the retry count to the URL', () => { + const retryCount = Math.floor(Math.random() * 100) + 1 // make sure it is never 0 + request( + createRequestData({ + retriesPerformedSoFar: retryCount, url: 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', }) ) expect(mockXHR.open).toHaveBeenCalledWith( 'GET', - 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', + `https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278&retry_count=${retryCount}`, true ) }) - describe('when xhr requests fail', () => { - it('does not error if the configured onXHRError is not a function', () => { - onXHRError = 'not a function' as unknown as XHRParams['onXHRError'] + it('calls the on response handler', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // noinspection JSConstantReassignment + mockXHR.status = 200 + request(createRequestData()) + mockXHR.onreadystatechange?.({} as Event) + expect(checkForLimiting).toHaveBeenCalledWith({ + statusCode: mockXHR.status, + responseText: mockXHR.responseText, + }) + }) + + describe('when the requests fail', () => { + it('does not error if the configured onError is not a function', () => { expect(() => { - xhr(xhrParams()) + request( + createRequestData({ + onError: 'not a function' as unknown as RequestData['onError'], + }) + ) mockXHR.onreadystatechange?.({} as Event) }).not.toThrow() }) it('calls the injected XHR error handler', () => { - //cannot use an auto-mock from jest as the code checks if onXHRError is a Function - let requestFromError - onXHRError = (req) => { - requestFromError = req - } - xhr(xhrParams()) + //cannot use an auto-mock from jest as the code checks if onError is a Function + let requestFromError: MinimalHTTPResponse | undefined + request( + createRequestData({ + onError: (req) => { + requestFromError = req + }, + }) + ) mockXHR.onreadystatechange?.({} as Event) - expect(requestFromError).toHaveProperty('status', 502) + expect(requestFromError).toHaveProperty('statusCode', 502) }) it('calls the on response handler - regardless of status', () => { @@ -109,9 +137,141 @@ describe('send-request', () => { // @ts-ignore // noinspection JSConstantReassignment mockXHR.status = Math.floor(Math.random() * 100) - xhr(xhrParams()) + request(createRequestData()) mockXHR.onreadystatechange?.({} as Event) - expect(checkForLimiting).toHaveBeenCalledWith(mockXHR) + expect(checkForLimiting).toHaveBeenCalledWith({ + statusCode: mockXHR.status, + responseText: mockXHR.responseText, + }) + }) + }) + }) + + describe('fetch', () => { + let createRequestData: (overrides?: Partial) => RequestData + let checkForLimiting: RequestData['onResponse'] + + beforeEach(() => { + checkForLimiting = jest.fn() + createRequestData = (overrides?: Partial) => { + return { + url: 'https://any.posthog-instance.com?ver=1.23.45', + data: {}, + headers: {}, + callback: () => {}, + retriesPerformedSoFar: undefined, + options: { + transport: 'fetch', + ...(overrides?.options || {}), + }, + + retryQueue: { + enqueue: () => {}, + } as Partial as RequestData['retryQueue'], + onResponse: checkForLimiting, + ...overrides, + } + } + + assignableWindow.fetch = jest.fn(() => { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + }) as any + }) + }) + + test('it performs the request with default params', () => { + request( + createRequestData({ + retriesPerformedSoFar: 0, + url: 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', + }) + ) + + expect(assignableWindow.fetch).toHaveBeenCalledWith( + `https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278`, + { + body: null, + headers: new Headers(), + keepalive: false, + method: 'GET', + } + ) + }) + + test('it adds the retry count to the URL', () => { + const retryCount = Math.floor(Math.random() * 100) + 1 // make sure it is never 0 + request( + createRequestData({ + retriesPerformedSoFar: retryCount, + url: 'https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278', + }) + ) + expect(assignableWindow.fetch).toHaveBeenCalledWith( + `https://any.posthog-instance.com/?ver=1.23.45&ip=7&_=1698404857278&retry_count=${retryCount}`, + { + body: null, + headers: new Headers(), + keepalive: false, + method: 'GET', + } + ) + }) + + it('calls the on response handler', async () => { + request(createRequestData()) + await flushPromises() + expect(checkForLimiting).toHaveBeenCalledWith({ + statusCode: 200, + responseText: '', + }) + }) + + describe('when the requests fail', () => { + beforeEach(() => { + assignableWindow.fetch = jest.fn( + () => + Promise.resolve({ + status: 502, + text: () => Promise.resolve('oh no!'), + }) as any + ) + }) + + it('does not error if the configured onError is not a function', () => { + expect(() => { + request( + createRequestData({ + onError: 'not a function' as unknown as RequestData['onError'], + }) + ) + }).not.toThrow() + }) + + it('calls the injected XHR error handler', async () => { + //cannot use an auto-mock from jest as the code checks if onError is a Function + let requestFromError: MinimalHTTPResponse | undefined + request( + createRequestData({ + onError: (req) => { + requestFromError = req + }, + }) + ) + await flushPromises() + + expect(requestFromError).toHaveProperty('statusCode', 502) + }) + + it('calls the on response handler - regardless of status', async () => { + request(createRequestData()) + await flushPromises() + expect(checkForLimiting).toHaveBeenCalledWith({ + statusCode: 502, + responseText: 'oh no!', + }) }) }) }) diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index 3ae4e6d7a..17a97e6ce 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -140,7 +140,6 @@ export class ExceptionObserver { */ sendExceptionEvent(properties: { [key: string]: any }) { this.instance.capture('$exception', properties, { - transport: 'XHR', method: 'POST', endpoint: EXCEPTION_INGESTION_ENDPOINT, _noTruncate: true, diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 4dda8e582..e6be439c2 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -834,7 +834,6 @@ export class SessionRecording { private _captureSnapshot(properties: Properties) { // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings. this.instance.capture('$snapshot', properties, { - transport: 'XHR', method: 'POST', endpoint: this._endpoint, _noTruncate: true, diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 8388c799e..f4871df71 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -21,7 +21,7 @@ import { clearOptInOut, hasOptedIn, hasOptedOut, optIn, optOut, userOptedOut } f import { cookieStore, localStore } from './storage' import { RequestQueue } from './request-queue' import { compressData, decideCompression } from './compression' -import { addParamsToURL, encodePostData, xhr } from './send-request' +import { addParamsToURL, encodePostData, request } from './send-request' import { RetryQueue } from './retry-queue' import { SessionIdManager } from './sessionid' import { @@ -58,6 +58,7 @@ import { logger } from './utils/logger' import { document, userAgent } from './utils/globals' import { SessionPropsManager } from './session-props' import { _isBlockedUA } from './utils/blocked-uas' +import { SUPPORTS_REQUEST } from './utils/request-utils' /* SIMPLE STYLE GUIDE: @@ -92,12 +93,11 @@ const PRIMARY_INSTANCE_NAME = 'posthog' */ // http://hacks.mozilla.org/2009/07/cross-site-xmlhttprequest-with-cors/ // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#withCredentials -const USE_XHR = window?.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest() // IE<10 does not support cross-origin XHR's but script tags // with defer won't block window.onload; ENQUEUE_REQUESTS // should only be true for Opera<12 -let ENQUEUE_REQUESTS = !USE_XHR && userAgent?.indexOf('MSIE') === -1 && userAgent?.indexOf('Mozilla') === -1 +let ENQUEUE_REQUESTS = !SUPPORTS_REQUEST && userAgent?.indexOf('MSIE') === -1 && userAgent?.indexOf('Mozilla') === -1 export const defaultConfig = (): PostHogConfig => ({ api_host: 'https://app.posthog.com', @@ -137,7 +137,7 @@ export const defaultConfig = (): PostHogConfig => ({ property_blacklist: [], respect_dnt: false, sanitize_properties: null, - xhr_headers: {}, // { header: value, header2: value } + request_headers: {}, // { header: value, header2: value } inapp_protocol: '//', inapp_link_new_window: false, request_batching: true, @@ -149,8 +149,8 @@ export const defaultConfig = (): PostHogConfig => ({ advanced_disable_feature_flags: false, advanced_disable_feature_flags_on_first_load: false, advanced_disable_toolbar_metrics: false, - on_xhr_error: (req) => { - const error = 'Bad HTTP status: ' + req.status + ' ' + req.statusText + on_request_error: (req) => { + const error = 'Bad HTTP status: ' + req.statusCode + ' ' + req.responseText logger.error(error) }, get_device_id: (uuid) => uuid, @@ -425,6 +425,9 @@ export class PostHog { } } + // Check for deprecated params that might still be in use + config.request_headers = config.request_headers || config.xhr_headers + this.set_config( _extend({}, defaultConfig(), config, { name: name, @@ -447,7 +450,7 @@ export class PostHog { this.persistence = new PostHogPersistence(this.config) this._requestQueue = new RequestQueue(this._handle_queued_event.bind(this)) - this._retryQueue = new RetryQueue(this.config.on_xhr_error, this.rateLimiter) + this._retryQueue = new RetryQueue(this.config.on_request_error, this.rateLimiter) this.__captureHooks = [] this.__request_queue = [] @@ -625,23 +628,23 @@ export class PostHog { return null } - if (USE_XHR) { + if (SUPPORTS_REQUEST) { return function (response) { callback(response, data) } - } else { - // if the user gives us a callback, we store as a random - // property on this instances jsc function and update our - // callback string to reflect that. - const jsc = this._jsc - const randomized_cb = '' + Math.floor(Math.random() * 100000000) - const callback_string = this.config.callback_fn + '[' + randomized_cb + ']' - jsc[randomized_cb] = function (response: any) { - delete jsc[randomized_cb] - callback(response, data) - } - return callback_string } + + // if the user gives us a callback, we store as a random + // property on this instances jsc function and update our + // callback string to reflect that. + const jsc = this._jsc + const randomized_cb = '' + Math.floor(Math.random() * 100000000) + const callback_string = this.config.callback_fn + '[' + randomized_cb + ']' + jsc[randomized_cb] = function (response: any) { + delete jsc[randomized_cb] + callback(response, data) + } + return callback_string } _handle_unload(): void { @@ -695,7 +698,7 @@ export class PostHog { } options = _extend(DEFAULT_OPTIONS, options || {}) - if (!USE_XHR) { + if (!SUPPORTS_REQUEST) { options.method = 'GET' } @@ -713,17 +716,17 @@ export class PostHog { // send beacon is a best-effort, fire-and-forget mechanism on page unload, // we don't want to throw errors here } - } else if (USE_XHR || !document) { + } else if (SUPPORTS_REQUEST || !document) { try { - xhr({ - url: url, - data: data, - headers: this.config.xhr_headers, - options: options, + request({ + url, + data, + headers: this.config.request_headers, + options, callback, retriesPerformedSoFar: 0, retryQueue: this._retryQueue, - onXHRError: this.config.on_xhr_error, + onError: this.config.on_request_error, onResponse: this.rateLimiter.checkForLimiting, }) } catch (e) { @@ -1686,7 +1689,7 @@ export class PostHog { * * // extra HTTP request headers to set for each API request, in * // the format {'Header-Name': value} - * xhr_headers: {} + * response_headers: {} * * // protocol for fetching in-app message resources, e.g. * // 'https://' or 'http://'; defaults to '//' (which defers to the diff --git a/src/rate-limiter.ts b/src/rate-limiter.ts index 5eea45fcf..2a738147d 100644 --- a/src/rate-limiter.ts +++ b/src/rate-limiter.ts @@ -1,3 +1,4 @@ +import { MinimalHTTPResponse } from 'types' import { logger } from './utils/logger' const oneMinuteInMilliseconds = 60 * 1000 @@ -18,9 +19,9 @@ export class RateLimiter { return new Date().getTime() < retryAfter } - public checkForLimiting = (xmlHttpRequest: XMLHttpRequest): void => { + public checkForLimiting = (httpResponse: MinimalHTTPResponse): void => { try { - const text = xmlHttpRequest.responseText + const text = httpResponse.responseText if (!text || !text.length) { return } diff --git a/src/retry-queue.ts b/src/retry-queue.ts index bea0c7c69..302c556fd 100644 --- a/src/retry-queue.ts +++ b/src/retry-queue.ts @@ -1,6 +1,6 @@ import { RequestQueueScaffold } from './base-request-queue' -import { encodePostData, xhr } from './send-request' -import { QueuedRequestData, RetryQueueElement } from './types' +import { encodePostData, request } from './send-request' +import { PostHogConfig, QueuedRequestData, RetryQueueElement } from './types' import { RateLimiter } from './rate-limiter' import { _isUndefined } from './utils/type-utils' @@ -32,15 +32,15 @@ export class RetryQueue extends RequestQueueScaffold { queue: RetryQueueElement[] isPolling: boolean areWeOnline: boolean - onXHRError: (failedRequest: XMLHttpRequest) => void + onRequestError: PostHogConfig['on_request_error'] rateLimiter: RateLimiter - constructor(onXHRError: (failedRequest: XMLHttpRequest) => void, rateLimiter: RateLimiter) { + constructor(onRequestError: PostHogConfig['on_request_error'], rateLimiter: RateLimiter) { super() this.isPolling = false this.queue = [] this.areWeOnline = true - this.onXHRError = onXHRError + this.onRequestError = onRequestError this.rateLimiter = rateLimiter if (!_isUndefined(window) && 'onLine' in window.navigator) { @@ -130,7 +130,7 @@ export class RetryQueue extends RequestQueueScaffold { return } - xhr({ + request({ url, data: data || {}, options: options || {}, @@ -138,7 +138,7 @@ export class RetryQueue extends RequestQueueScaffold { retriesPerformedSoFar: retriesPerformedSoFar || 0, callback, retryQueue: this, - onXHRError: this.onXHRError, + onError: this.onRequestError, onResponse: this.rateLimiter.checkForLimiting, }) } diff --git a/src/send-request.ts b/src/send-request.ts index 08bc0dace..15508a9ea 100644 --- a/src/send-request.ts +++ b/src/send-request.ts @@ -1,10 +1,11 @@ import { _each } from './utils' import Config from './config' -import { PostData, XHROptions, XHRParams } from './types' -import { _HTTPBuildQuery } from './utils/request-utils' +import { PostData, XHROptions, RequestData, MinimalHTTPResponse } from './types' +import { SUPPORTS_FETCH, _HTTPBuildQuery } from './utils/request-utils' import { _isArray, _isFunction, _isNumber, _isUint8Array, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window } from './utils/globals' export const addParamsToURL = ( url: string, @@ -61,7 +62,81 @@ export const encodePostData = (data: PostData | Uint8Array, options: Partial { + // NOTE: Until we are confident with it, we only use fetch if explicitly told so + if (window && SUPPORTS_FETCH && params.options.transport === 'fetch') { + const body = encodePostData(params.data, params.options) + + const headers = new Headers() + _each(headers, function (headerValue, headerName) { + headers.append(headerName, headerValue) + }) + + if (params.options.method === 'POST' && !params.options.blob) { + headers.append('Content-Type', 'application/x-www-form-urlencoded') + } + + let url = params.url + + if (_isNumber(params.retriesPerformedSoFar) && params.retriesPerformedSoFar > 0) { + url = addParamsToURL(url, { retry_count: params.retriesPerformedSoFar }, {}) + } + + window + .fetch(url, { + method: params.options?.method || 'GET', + headers, + keepalive: params.options.method === 'POST', + body, + }) + .then((response) => { + const statusCode = response.status + // Report to the callback handlers + return response.text().then((responseText) => { + params.onResponse?.({ + statusCode, + responseText, + }) + + if (statusCode === 200) { + try { + params.callback?.(JSON.parse(responseText)) + } catch (e) { + logger.error(e) + } + return + } + + if (_isFunction(params.onError)) { + params.onError({ + statusCode, + responseText, + }) + } + + // don't retry errors between 400 and 500 inclusive + if (statusCode < 400 || statusCode > 500) { + params.retryQueue.enqueue({ + ...params, + headers, + retriesPerformedSoFar: (params.retriesPerformedSoFar || 0) + 1, + }) + } + params.callback?.({ status: 0 }) + }) + }) + .catch((error) => { + logger.error(error) + params.callback?.({ status: 0 }) + }) + + return + } + + return xhr(params) +} + +const xhr = ({ url, data, headers, @@ -69,10 +144,10 @@ export const xhr = ({ callback, retriesPerformedSoFar, retryQueue, - onXHRError, + onError, timeout = 60000, onResponse, -}: XHRParams) => { +}: RequestData) => { if (_isNumber(retriesPerformedSoFar) && retriesPerformedSoFar > 0) { url = addParamsToURL(url, { retry_count: retriesPerformedSoFar }, {}) } @@ -97,7 +172,11 @@ export const xhr = ({ req.onreadystatechange = () => { // XMLHttpRequest.DONE == 4, except in safari 4 if (req.readyState === 4) { - onResponse?.(req) + const minimalResponseSummary: MinimalHTTPResponse = { + statusCode: req.status, + responseText: req.responseText, + } + onResponse?.(minimalResponseSummary) if (req.status === 200) { if (callback) { let response @@ -110,8 +189,8 @@ export const xhr = ({ callback(response) } } else { - if (_isFunction(onXHRError)) { - onXHRError(req) + if (_isFunction(onError)) { + onError(minimalResponseSummary) } // don't retry errors between 400 and 500 inclusive diff --git a/src/types.ts b/src/types.ts index 3c4189345..841e7798f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,7 @@ export type UUIDVersion = 'og' | 'v7' export interface PostHogConfig { api_host: string api_method: string - api_transport: string + api_transport?: 'XHR' | 'fetch' ui_host: string | null token: string autocapture: boolean | AutocaptureConfig @@ -97,8 +97,12 @@ export interface PostHogConfig { opt_in_site_apps: boolean respect_dnt: boolean property_blacklist: string[] - xhr_headers: { [header_name: string]: string } - on_xhr_error: (failedRequest: XMLHttpRequest) => void + request_headers: { [header_name: string]: string } + on_request_error: (error: MinimalHTTPResponse) => void + /** @deprecated - use `request_headers` instead */ + xhr_headers?: { [header_name: string]: string } + /** @deprecated - use `on_request_error` instead */ + on_xhr_error?: (failedRequest: XMLHttpRequest) => void inapp_protocol: string inapp_link_new_window: boolean request_batching: boolean @@ -181,7 +185,7 @@ export enum Compression { } export interface XHROptions { - transport?: 'XHR' | 'sendBeacon' + transport?: 'XHR' | 'fetch' | 'sendBeacon' method?: 'POST' | 'GET' urlQueryArgs?: { compression: Compression } verbose?: boolean @@ -213,11 +217,17 @@ export interface QueuedRequestData { retriesPerformedSoFar?: number } -export interface XHRParams extends QueuedRequestData { +// Minimal class to allow interop between different request methods (xhr / fetch) +export interface MinimalHTTPResponse { + statusCode: number + responseText: string +} + +export interface RequestData extends QueuedRequestData { retryQueue: RetryQueue - onXHRError: (req: XMLHttpRequest) => void timeout?: number - onResponse?: (req: XMLHttpRequest) => void + onError?: (req: MinimalHTTPResponse) => void + onResponse?: (req: MinimalHTTPResponse) => void } export interface DecideResponse { diff --git a/src/utils/request-utils.ts b/src/utils/request-utils.ts index a097001d6..1acf562e2 100644 --- a/src/utils/request-utils.ts +++ b/src/utils/request-utils.ts @@ -1,11 +1,15 @@ import { _each, _isValidRegex } from './' -import { _isArray, _isUndefined } from './type-utils' +import { _isArray, _isFunction, _isUndefined } from './type-utils' import { logger } from './logger' -import { document } from './globals' +import { document, window } from './globals' const localDomains = ['localhost', '127.0.0.1'] +export const SUPPORTS_XHR = !!(window?.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest()) +export const SUPPORTS_FETCH = !!(window?.fetch && _isFunction(window?.fetch)) +export const SUPPORTS_REQUEST = SUPPORTS_XHR || SUPPORTS_FETCH + /** * IE11 doesn't support `new URL` * so we can create an anchor element and use that to parse the URL