Skip to content

Commit

Permalink
fix: Send beacon request encoding (#1068)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored Mar 11, 2024
1 parent cdb1634 commit ada306b
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 12 deletions.
84 changes: 83 additions & 1 deletion src/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ jest.mock('../utils/globals', () => ({
...jest.requireActual('../utils/globals'),
fetch: jest.fn(),
XMLHttpRequest: jest.fn(),
navigator: {
sendBeacon: jest.fn(),
},
}))

import { fetch, XMLHttpRequest } from '../utils/globals'
import { fetch, XMLHttpRequest, navigator } from '../utils/globals'

jest.mock('../config', () => ({ DEBUG: false, LIB_VERSION: '1.23.45' }))

Expand All @@ -23,6 +26,7 @@ const flushPromises = async () => {
describe('request', () => {
const mockedFetch: jest.MockedFunction<any> = fetch as jest.MockedFunction<any>
const mockedXMLHttpRequest: jest.MockedFunction<any> = XMLHttpRequest as jest.MockedFunction<any>
const mockedNavigator: jest.Mocked<typeof navigator> = navigator as jest.Mocked<typeof navigator>
const mockedXHR = {
open: jest.fn(),
setRequestHeader: jest.fn(),
Expand Down Expand Up @@ -240,4 +244,82 @@ describe('request', () => {
)
})
})

describe('sendBeacon', () => {
beforeEach(() => {
transport = 'sendBeacon'
})

it("should encode data to a string and send it as a blob if it's a POST request", async () => {
request(
createRequest({
url: 'https://any.posthog-instance.com/',
method: 'POST',
data: { my: 'content' },
})
)
expect(mockedNavigator?.sendBeacon).toHaveBeenCalledWith(
'https://any.posthog-instance.com/?_=1700000000000&ver=1.23.45&beacon=1',
expect.any(Blob)
)

const blob = mockedNavigator?.sendBeacon.mock.calls[0][1] as Blob

const reader = new FileReader()
const result = await new Promise((resolve) => {
reader.onload = () => resolve(reader.result)
reader.readAsText(blob)
})

expect(result).toBe('data=%7B%22my%22%3A%22content%22%7D')
})

it('should respect base64 compression', async () => {
request(
createRequest({
url: 'https://any.posthog-instance.com/',
method: 'POST',
compression: Compression.Base64,
data: { my: 'content' },
})
)
expect(mockedNavigator?.sendBeacon).toHaveBeenCalledWith(
'https://any.posthog-instance.com/?_=1700000000000&ver=1.23.45&compression=base64&beacon=1',
expect.any(Blob)
)

const blob = mockedNavigator?.sendBeacon.mock.calls[0][1] as Blob
const reader = new FileReader()
const result = await new Promise((resolve) => {
reader.onload = () => resolve(reader.result)
reader.readAsText(blob)
})

expect(result).toBe('data=eyJteSI6ImNvbnRlbnQifQ%3D%3D')
})

it('should respect gzip compression', async () => {
request(
createRequest({
url: 'https://any.posthog-instance.com/',
method: 'POST',
compression: Compression.GZipJS,
data: { my: 'content' },
})
)
expect(mockedNavigator?.sendBeacon).toHaveBeenCalledWith(
'https://any.posthog-instance.com/?_=1700000000000&ver=1.23.45&compression=gzip-js&beacon=1',
expect.any(Blob)
)

const blob = mockedNavigator?.sendBeacon.mock.calls[0][1] as Blob
const reader = new FileReader()
const result = await new Promise((resolve) => {
reader.onload = () => resolve(reader.result)
reader.readAsText(blob)
})

expect(result).toMatchInlineSnapshot(`"��VʭT�RJ��+I�+Q��ԮM"`)
})
})
})
27 changes: 17 additions & 10 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Compression, RequestOptions, RequestResponse } from './types'
import { _formDataToQuery } from './utils/request-utils'

import { logger } from './utils/logger'
import { fetch, document, window, XMLHttpRequest, AbortController } from './utils/globals'
import { fetch, document, XMLHttpRequest, AbortController, navigator } from './utils/globals'
import { gzipSync, strToU8 } from 'fflate'

// eslint-disable-next-line compat/compat
Expand All @@ -22,8 +22,8 @@ export const request = (_options: RequestOptions) => {
compression: options.compression,
})

if (options.transport === 'sendBeacon' && window?.navigator?.sendBeacon) {
return sendBeacon(options)
if (options.transport === 'sendBeacon' && navigator?.sendBeacon) {
return _sendBeacon(options)
}

// NOTE: Until we are confident with it, we only use fetch if explicitly told so
Expand Down Expand Up @@ -64,21 +64,23 @@ const encodePostData = ({ data, compression, transport, method }: RequestOptions
return null
}

// Gzip is always a blob
if (compression === Compression.GZipJS) {
const gzipData = gzipSync(strToU8(JSON.stringify(data)), { mtime: 0 })
return new Blob([gzipData], { type: 'text/plain' })
}

// sendBeacon is always a blob but can be base64 encoded internally
if (transport === 'sendBeacon') {
const body = compression === Compression.Base64 ? _base64Encode(JSON.stringify(data)) : data
return new Blob([encodeToDataString(body)], { type: 'application/x-www-form-urlencoded' })
}

if (compression === Compression.Base64) {
const b64data = _base64Encode(JSON.stringify(data))
return encodeToDataString(b64data)
}

if (transport === 'sendBeacon') {
const body = encodeToDataString(data)
return new Blob([body], { type: 'application/x-www-form-urlencoded' })
}

if (method !== 'POST') {
return null
}
Expand Down Expand Up @@ -184,12 +186,17 @@ const _fetch = (options: RequestOptions) => {
return
}

const sendBeacon = (options: RequestOptions) => {
const _sendBeacon = (options: RequestOptions) => {
// beacon documentation https://w3c.github.io/beacon/
// beacons format the message and use the type property

const url = extendURLParams(options.url, {
beacon: '1',
})

try {
// eslint-disable-next-line compat/compat
window?.navigator?.sendBeacon(options.url, encodePostData(options))
navigator!.sendBeacon!(url, encodePostData(options))
} catch (e) {
// send beacon is a best-effort, fire-and-forget mechanism on page unload,
// we don't want to throw errors here
Expand Down
2 changes: 1 addition & 1 deletion src/utils/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const ArrayProto = Array.prototype
export const nativeForEach = ArrayProto.forEach
export const nativeIndexOf = ArrayProto.indexOf

const navigator = global?.navigator
export const navigator = global?.navigator
export const document = global?.document
export const location = global?.location
export const fetch = global?.fetch
Expand Down

0 comments on commit ada306b

Please sign in to comment.