From 62c96e712c308d59a202420bd0508d8059db37f9 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 15 Nov 2023 12:26:58 +0100 Subject: [PATCH] fix: Window or document access across the code (#894) --- .eslintrc.js | 8 +++ .../replay/sessionrecording.test.ts | 53 ++++++++++--------- src/__tests__/extensions/toolbar.test.ts | 31 +++++------ src/__tests__/storage.test.ts | 4 +- src/__tests__/utils/event-utils.test.ts | 2 - src/autocapture-utils.ts | 3 +- src/autocapture.ts | 15 ++++-- src/decide.ts | 3 +- src/extensions/exception-autocapture/index.ts | 13 +++-- src/extensions/replay/sessionrecording.ts | 16 +++--- src/extensions/replay/web-performance.ts | 1 + src/extensions/surveys.ts | 12 ++++- src/extensions/toolbar.ts | 28 ++++++---- src/gdpr-utils.ts | 4 +- src/loader-exception-autocapture.ts | 8 +-- src/loader-recorder-v2.ts | 11 ++-- src/loader-recorder.ts | 12 ++--- src/loader-surveys.ts | 8 +-- src/page-view.ts | 24 +++++---- src/posthog-core.ts | 39 +++++++------- src/posthog-surveys.ts | 10 ++-- src/retry-queue.ts | 3 +- src/session-props.ts | 8 +-- src/sessionid.ts | 2 +- src/storage.ts | 27 +++++++--- src/utils/event-utils.ts | 27 ++++++---- src/utils/globals.ts | 12 +++-- src/utils/index.ts | 11 ++-- src/utils/logger.ts | 9 +++- src/uuidv7.ts | 2 +- 30 files changed, 239 insertions(+), 167 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index bbdfa9260..02b03cf7f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -53,6 +53,13 @@ module.exports = { }, }, overrides: [ + { + files: 'src/**/*', + rules: { + ...rules, + 'no-restricted-globals': ['error', 'document', 'window'], + }, + }, { files: 'src/__tests__/**/*', // the same set of config as in the root @@ -62,6 +69,7 @@ module.exports = { rules: { ...rules, 'no-console': 'off', + 'no-restricted-globals': 'off', }, }, { diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index c82747c24..c74dd883f 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -19,6 +19,7 @@ import { RECORDING_MAX_EVENT_SIZE, SessionRecording, } from '../../../extensions/replay/sessionrecording' +import { assignableWindow } from '../../../utils/globals' // Type and source defined here designate a non-user-generated recording event @@ -52,8 +53,8 @@ describe('SessionRecording', () => { let onFeatureFlagsCallback: ((flags: string[]) => void) | null beforeEach(() => { - ;(window as any).rrwebRecord = jest.fn() - ;(window as any).rrwebConsoleRecord = { + assignableWindow.rrwebRecord = jest.fn() + assignableWindow.rrwebConsoleRecord = { getRecordConsolePlugin: jest.fn(), } @@ -193,7 +194,7 @@ describe('SessionRecording', () => { beforeEach(() => { jest.spyOn(sessionRecording, 'startRecordingIfEnabled') ;(loadScript as any).mockImplementation((_path: any, callback: any) => callback()) - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) @@ -277,11 +278,11 @@ describe('SessionRecording', () => { describe('recording', () => { beforeEach(() => { const mockFullSnapshot = jest.fn() - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) - ;(window as any).rrwebRecord.takeFullSnapshot = mockFullSnapshot + assignableWindow.rrwebRecord.takeFullSnapshot = mockFullSnapshot ;(loadScript as any).mockImplementation((_path: any, callback: any) => callback()) }) @@ -356,7 +357,7 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', sampleRate: '0.50' }, }) ) - const emitValues = [] + const emitValues: string[] = [] let lastSessionId = sessionRecording['sessionId'] for (let i = 0; i < 100; i++) { @@ -368,7 +369,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionId']).not.toBe(lastSessionId) lastSessionId = sessionRecording['sessionId'] - emitValues.push(sessionRecording['status']) + emitValues.push(sessionRecording.status) } // the random number generator won't always be exactly 0.5, but it should be close @@ -384,7 +385,7 @@ describe('SessionRecording', () => { // maskAllInputs should change from default // someUnregisteredProp should not be present - expect((window as any).rrwebRecord).toHaveBeenCalledWith({ + expect(assignableWindow.rrwebRecord).toHaveBeenCalledWith({ emit: expect.anything(), maskAllInputs: false, blockClass: 'ph-no-capture', @@ -680,7 +681,7 @@ describe('SessionRecording', () => { sessionRecording.startRecordingIfEnabled() - expect((window as any).rrwebConsoleRecord.getRecordConsolePlugin).not.toHaveBeenCalled() + expect(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin).not.toHaveBeenCalled() }) it('if enabled, plugin is used', () => { @@ -688,7 +689,7 @@ describe('SessionRecording', () => { sessionRecording.startRecordingIfEnabled() - expect((window as any).rrwebConsoleRecord.getRecordConsolePlugin).toHaveBeenCalled() + expect(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin).toHaveBeenCalled() }) }) @@ -710,32 +711,32 @@ describe('SessionRecording', () => { sessionIdGeneratorMock.mockImplementation(() => 'newSessionId') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalled() }) it('sends a full snapshot if there is a new window id and the event is not type FullSnapshot or Meta', () => { sessionIdGeneratorMock.mockImplementation(() => 'old-session-id') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalled() }) it('does not send a full snapshot if there is a new session/window id and the event is type FullSnapshot or Meta', () => { sessionIdGeneratorMock.mockImplementation(() => 'newSessionId') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot({ type: META_EVENT_TYPE })) - expect((window as any).rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() }) it('does not send a full snapshot if there is not a new session or window id', () => { - ;(window as any).rrwebRecord.takeFullSnapshot.mockClear() + assignableWindow.rrwebRecord.takeFullSnapshot.mockClear() sessionIdGeneratorMock.mockImplementation(() => 'old-session-id') windowIdGeneratorMock.mockImplementation(() => 'old-window-id') sessionManager.resetSessionId() _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() }) }) @@ -843,7 +844,7 @@ describe('SessionRecording', () => { it('takes a full snapshot for the first _emit', () => { emitAtDateTime(startingDate) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('does not take a full snapshot for the second _emit', () => { @@ -857,7 +858,7 @@ describe('SessionRecording', () => { startingDate.getMinutes() + 1 ) ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('does not change session id for a second _emit', () => { @@ -899,7 +900,7 @@ describe('SessionRecording', () => { startingDate.getMinutes() + 2 ) ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('sends a full snapshot if the session is rotated because session has been inactive for 30 minutes', () => { @@ -925,7 +926,7 @@ describe('SessionRecording', () => { emitAtDateTime(inactivityThresholdLater) expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) it('sends a full snapshot if the session is rotated because max time has passed', () => { @@ -950,7 +951,7 @@ describe('SessionRecording', () => { emitAtDateTime(moreThanADayLater) expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) @@ -960,7 +961,7 @@ describe('SessionRecording', () => { const lastActivityTimestamp = sessionRecording['_lastActivityTimestamp'] expect(lastActivityTimestamp).toBeGreaterThan(0) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) _emit({ event: 123, @@ -973,7 +974,7 @@ describe('SessionRecording', () => { expect(sessionRecording['isIdle']).toEqual(false) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) _emit({ event: 123, @@ -985,7 +986,7 @@ describe('SessionRecording', () => { }) expect(sessionRecording['isIdle']).toEqual(false) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) // this triggers idle state and isn't a user interaction so does not take a full snapshot _emit({ @@ -998,7 +999,7 @@ describe('SessionRecording', () => { }) expect(sessionRecording['isIdle']).toEqual(true) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) // this triggers idle state _and_ is a user interaction, so we take a full snapshot _emit({ @@ -1013,7 +1014,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_lastActivityTimestamp']).toEqual( lastActivityTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 2000 ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) }) @@ -1046,7 +1047,7 @@ describe('SessionRecording', () => { describe('buffering minimum duration', () => { beforeEach(() => { - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) diff --git a/src/__tests__/extensions/toolbar.test.ts b/src/__tests__/extensions/toolbar.test.ts index 27f63b418..c938cdaaa 100644 --- a/src/__tests__/extensions/toolbar.test.ts +++ b/src/__tests__/extensions/toolbar.test.ts @@ -2,7 +2,7 @@ import { Toolbar } from '../../extensions/toolbar' import { _isString, _isUndefined } from '../../utils/type-utils' import { PostHog } from '../../posthog-core' import { PostHogConfig, ToolbarParams } from '../../types' -import { window } from '../../utils/globals' +import { assignableWindow, window } from '../../utils/globals' jest.mock('../../utils', () => ({ ...jest.requireActual('../../utils'), @@ -31,8 +31,8 @@ describe('Toolbar', () => { }) beforeEach(() => { - ;(window as any).ph_load_toolbar = jest.fn() - delete (window as any)['_postHogToolbarLoaded'] + assignableWindow.ph_load_toolbar = jest.fn() + delete assignableWindow['_postHogToolbarLoaded'] }) describe('maybeLoadToolbar', () => { @@ -40,7 +40,8 @@ describe('Toolbar', () => { getItem: jest.fn(), setItem: jest.fn(), } - const history = { replaceState: jest.fn() } + const storage = localStorage as unknown as Storage + const history = { replaceState: jest.fn() } as unknown as History const defaultHashState = { action: 'ph_authorize', @@ -71,7 +72,7 @@ describe('Toolbar', () => { .join('&') } - const aLocation = (hash?: string) => { + const aLocation = (hash?: string): Location => { if (_isUndefined(hash)) { hash = withHash(withHashParamsFrom()) } @@ -80,7 +81,7 @@ describe('Toolbar', () => { hash: `#${hash}`, pathname: 'pathname', search: '?search', - } + } as Location } beforeEach(() => { @@ -91,7 +92,7 @@ describe('Toolbar', () => { it('should initialize the toolbar when the hash state contains action "ph_authorize"', () => { // the default hash state in the test setup contains the action "ph_authorize" - toolbar.maybeLoadToolbar(aLocation(), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(), storage, history) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, @@ -105,7 +106,7 @@ describe('Toolbar', () => { localStorage.getItem.mockImplementation(() => JSON.stringify(toolbarParams)) const hashState = { ...defaultHashState, action: undefined } - toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom(hashState))), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom(hashState))), storage, history) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, @@ -114,14 +115,14 @@ describe('Toolbar', () => { }) it('should NOT initialize the toolbar when the activation query param does not exist', () => { - expect(toolbar.maybeLoadToolbar(aLocation(''), localStorage, history)).toEqual(false) + expect(toolbar.maybeLoadToolbar(aLocation(''), storage, history)).toEqual(false) expect(toolbar.loadToolbar).not.toHaveBeenCalled() }) it('should return false when parsing invalid JSON from fragment state', () => { expect( - toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom('literally'))), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom('literally'))), storage, history) ).toEqual(false) expect(toolbar.loadToolbar).not.toHaveBeenCalled() }) @@ -129,7 +130,7 @@ describe('Toolbar', () => { it('should work if calling toolbar params `__posthog`', () => { toolbar.maybeLoadToolbar( aLocation(withHash(withHashParamsFrom(defaultHashState, '__posthog'))), - localStorage, + storage, history ) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, ...defaultHashState, source: 'url' }) @@ -138,7 +139,7 @@ describe('Toolbar', () => { it('should use the apiURL in the hash if available', () => { toolbar.maybeLoadToolbar( aLocation(withHash(withHashParamsFrom({ ...defaultHashState, apiURL: 'blabla' }))), - localStorage, + storage, history ) @@ -154,7 +155,7 @@ describe('Toolbar', () => { describe('load and close toolbar', () => { it('should persist for next time', () => { expect(toolbar.loadToolbar(toolbarParams)).toBe(true) - expect(JSON.parse((window as any).localStorage.getItem('_postHogToolbarParams'))).toEqual({ + expect(JSON.parse(window.localStorage.getItem('_postHogToolbarParams') ?? '')).toEqual({ ...toolbarParams, apiURL: 'http://api.example.com', }) @@ -162,7 +163,7 @@ describe('Toolbar', () => { it('should load if not previously loaded', () => { expect(toolbar.loadToolbar(toolbarParams)).toBe(true) - expect((window as any).ph_load_toolbar).toHaveBeenCalledWith( + expect(assignableWindow.ph_load_toolbar).toHaveBeenCalledWith( { ...toolbarParams, apiURL: 'http://api.example.com' }, instance ) @@ -181,7 +182,7 @@ describe('Toolbar', () => { it('should load if not previously loaded', () => { expect(toolbar.loadToolbar(minimalToolbarParams)).toBe(true) - expect((window as any).ph_load_toolbar).toHaveBeenCalledWith( + expect(assignableWindow.ph_load_toolbar).toHaveBeenCalledWith( { ...minimalToolbarParams, apiURL: 'http://api.example.com', diff --git a/src/__tests__/storage.test.ts b/src/__tests__/storage.test.ts index a1e6191c9..f36e5094f 100644 --- a/src/__tests__/storage.test.ts +++ b/src/__tests__/storage.test.ts @@ -36,7 +36,9 @@ describe('sessionStore', () => { expected: '', }, ])(`%s subdomain check`, ({ candidate, expected }) => { - expect(seekFirstNonPublicSubDomain(candidate, mockDocumentDotCookie)).toEqual(expected) + expect(seekFirstNonPublicSubDomain(candidate, mockDocumentDotCookie as unknown as Document)).toEqual( + expected + ) }) }) diff --git a/src/__tests__/utils/event-utils.test.ts b/src/__tests__/utils/event-utils.test.ts index 1e996c227..4dd7027e5 100644 --- a/src/__tests__/utils/event-utils.test.ts +++ b/src/__tests__/utils/event-utils.test.ts @@ -1,8 +1,6 @@ import { _info } from '../../utils/event-utils' import * as globals from '../../utils/globals' -jest.mock('../../utils/globals') - describe(`event-utils`, () => { describe('properties', () => { it('should have $host and $pathname in properties', () => { diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index c9172d854..5f2d652cd 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -8,6 +8,7 @@ import { _each, _includes, _trim } from './utils' import { _isNull, _isString, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window } from './utils/globals' export function getClassName(el: Element): string { switch (typeof el.className) { @@ -109,7 +110,7 @@ export function shouldCaptureDomEvent( event: Event, autocaptureConfig: AutocaptureConfig | undefined = undefined ): boolean { - if (!el || isTag(el, 'html') || !isElementNode(el)) { + if (!window || !el || isTag(el, 'html') || !isElementNode(el)) { return false } diff --git a/src/autocapture.ts b/src/autocapture.ts index d2f4713d7..86faa9be1 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -21,6 +21,7 @@ import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' import { _isBoolean, _isFunction, _isNull, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window, document } from './utils/globals' function limitText(length: number, text: string): string { if (text.length > length) { @@ -131,7 +132,7 @@ const autocapture = { _extractCustomPropertyValue: function (customProperty: AutoCaptureCustomProperty): string { const propValues: string[] = [] - _each(document.querySelectorAll(customProperty['css_selector']), function (matchedElem) { + _each(document?.querySelectorAll(customProperty['css_selector']), function (matchedElem) { let value if (['input', 'select'].indexOf(matchedElem.tagName.toLowerCase()) > -1) { @@ -152,7 +153,7 @@ const autocapture = { const props: Properties = {} // will be deleted _each(this._customProperties, (customProperty) => { _each(customProperty['event_selectors'], (eventSelector) => { - const eventElements = document.querySelectorAll(eventSelector) + const eventElements = document?.querySelectorAll(eventSelector) _each(eventElements, (eventElement) => { if (_includes(targetElementList, eventElement) && shouldCaptureElement(eventElement)) { props[customProperty['name']] = this._extractCustomPropertyValue(customProperty) @@ -270,12 +271,18 @@ const autocapture = { // only reason is to stub for unit tests // since you can't override window.location props _navigate: function (href: string): void { + if (!window) { + return + } window.location.href = href }, _addDomEventHandlers: function (instance: PostHog): void { + if (!window || !document) { + return + } const handler = (e: Event) => { - e = e || window.event + e = e || window?.event this._captureEvent(e, instance) } _register_event(document, 'submit', handler, false, true) @@ -358,7 +365,7 @@ const autocapture = { }, isBrowserSupported: function (): boolean { - return _isFunction(document.querySelectorAll) + return _isFunction(document?.querySelectorAll) }, } diff --git a/src/decide.ts b/src/decide.ts index fb4798566..e8b05ae21 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -6,6 +6,7 @@ import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './con import { _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window, document, assignableWindow } from './utils/globals' export class Decide { instance: PostHog @@ -112,7 +113,7 @@ export class Decide { apiHost[apiHost.length - 1] === '/' && url[0] === '/' ? url.substring(1) : url, ].join('') - ;(window as any)[`__$$ph_site_app_${id}`] = this.instance + assignableWindow[`__$$ph_site_app_${id}`] = this.instance loadScript(scriptUrl, (err) => { if (err) { diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index f0d093d23..3ae4e6d7a 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -18,8 +18,8 @@ export const extendPostHog = (instance: PostHog, response: DecideResponse) => { export class ExceptionObserver { instance: PostHog remoteEnabled: boolean | undefined - private originalOnErrorHandler: typeof window['onerror'] | null | undefined = undefined - private originalOnUnhandledRejectionHandler: typeof window['onunhandledrejection'] | null | undefined = undefined + private originalOnErrorHandler: Window['onerror'] | null | undefined = undefined + private originalOnUnhandledRejectionHandler: Window['onunhandledrejection'] | null | undefined = undefined private errorsToIgnore: RegExp[] = [] @@ -28,7 +28,7 @@ export class ExceptionObserver { } startCapturing() { - if (!this.isEnabled() || (window.onerror as any)?.__POSTHOG_INSTRUMENTED__) { + if (!window || !this.isEnabled() || (window.onerror as any)?.__POSTHOG_INSTRUMENTED__) { return } @@ -56,7 +56,7 @@ export class ExceptionObserver { const errorProperties: ErrorProperties = unhandledRejectionToProperties(args) this.sendExceptionEvent(errorProperties) - if (this.originalOnUnhandledRejectionHandler) { + if (window && this.originalOnUnhandledRejectionHandler) { // eslint-disable-next-line prefer-rest-params return this.originalOnUnhandledRejectionHandler.apply(window, args) } @@ -71,6 +71,9 @@ export class ExceptionObserver { } stopCapturing() { + if (!window) { + return + } if (!_isUndefined(this.originalOnErrorHandler)) { window.onerror = this.originalOnErrorHandler this.originalOnErrorHandler = null @@ -85,7 +88,7 @@ export class ExceptionObserver { } isCapturing() { - return !!(window.onerror as any)?.__POSTHOG_INSTRUMENTED__ + return !!(window?.onerror as any)?.__POSTHOG_INSTRUMENTED__ } isEnabled() { diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 4da866dac..9f4fd8cfd 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -22,7 +22,7 @@ import { _timestamp, loadScript } from '../../utils' import { _isBoolean, _isFunction, _isNull, _isNumber, _isObject, _isString, _isUndefined } from '../../utils/type-utils' import { logger } from '../../utils/logger' -import { window } from '../../utils/globals' +import { assignableWindow, window } from '../../utils/globals' import { buildNetworkRequestOptions } from './config' const BASE_ENDPOINT = '/s/' @@ -133,7 +133,7 @@ export class SessionRecording { private get isRecordingEnabled() { const enabled_server_side = !!this.instance.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE) const enabled_client_side = !this.instance.config.disable_session_recording - return enabled_server_side && enabled_client_side + return window && enabled_server_side && enabled_client_side } private get isConsoleLogCaptureEnabled() { @@ -192,7 +192,7 @@ export class SessionRecording { this.stopRrweb = undefined this.receivedDecide = false - window.addEventListener('beforeunload', () => { + window?.addEventListener('beforeunload', () => { this._flushBuffer() }) @@ -481,12 +481,12 @@ export class SessionRecording { const plugins = [] - if ((window as any).rrwebConsoleRecord && this.isConsoleLogCaptureEnabled) { - plugins.push((window as any).rrwebConsoleRecord.getRecordConsolePlugin()) + if (assignableWindow.rrwebConsoleRecord && this.isConsoleLogCaptureEnabled) { + plugins.push(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin()) } - if (this.networkPayloadCapture && _isFunction((window as any).getRecordNetworkPlugin)) { + if (this.networkPayloadCapture && _isFunction(assignableWindow.getRecordNetworkPlugin)) { plugins.push( - (window as any).getRecordNetworkPlugin( + assignableWindow.getRecordNetworkPlugin( buildNetworkRequestOptions(this.instance.config, this.networkPayloadCapture) ) ) @@ -507,7 +507,7 @@ export class SessionRecording { // so we catch all errors. try { if (eventName === '$pageview') { - const href = this._maskUrl(window.location.href) + const href = window ? this._maskUrl(window.location.href) : '' if (!href) { return } diff --git a/src/extensions/replay/web-performance.ts b/src/extensions/replay/web-performance.ts index d90cc0757..c77a07b62 100644 --- a/src/extensions/replay/web-performance.ts +++ b/src/extensions/replay/web-performance.ts @@ -4,6 +4,7 @@ import { isLocalhost } from '../../utils/request-utils' import { _isUndefined } from '../../utils/type-utils' import { logger } from '../../utils/logger' +import { window } from '../../utils/globals' const PERFORMANCE_EVENTS_MAPPING: { [key: string]: number } = { // BASE_PERFORMANCE_EVENT_COLUMNS diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index 52f783be7..b8c6148a3 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -10,6 +10,11 @@ import { } from '../posthog-surveys-types' import { _isUndefined } from '../utils/type-utils' +import { window as _window, document as _document } from '../utils/globals' + +// We cast the types here which is dangerous but protected by the top level generateSurveys call +const window = _window as Window & typeof globalThis +const document = _document as Document const satisfiedEmoji = '' @@ -896,14 +901,19 @@ function showQuestion(n: number, surveyId: string) { function nextQuestion(currentQuestionIdx: number, surveyId: string) { // figure out which tab to display const tabs = document - .getElementsByClassName(`PostHogSurvey${surveyId}`)[0] + ?.getElementsByClassName(`PostHogSurvey${surveyId}`)[0] ?.shadowRoot?.querySelectorAll('.tab') as NodeListOf tabs[currentQuestionIdx].style.display = 'none' showQuestion(currentQuestionIdx + 1, surveyId) } +// This is the main exported function export function generateSurveys(posthog: PostHog) { + // NOTE: Important to ensure we never try and run surveys without a window environment + if (!document || !window) { + return + } callSurveys(posthog, true) // recalculate surveys every 3 seconds to check if URL or selectors have changed diff --git a/src/extensions/toolbar.ts b/src/extensions/toolbar.ts index bf91cf914..4aa6be883 100644 --- a/src/extensions/toolbar.ts +++ b/src/extensions/toolbar.ts @@ -4,11 +4,11 @@ import { DecideResponse, ToolbarParams } from '../types' import { POSTHOG_MANAGED_HOSTS } from './cloud' import { _getHashParam } from '../utils/request-utils' import { logger } from '../utils/logger' -import { window } from '../utils/globals' +import { window, document, assignableWindow } from '../utils/globals' // TRICKY: Many web frameworks will modify the route on load, potentially before posthog is initialized. // To get ahead of this we grab it as soon as the posthog-js is parsed -const STATE_FROM_WINDOW = window.location +const STATE_FROM_WINDOW = window?.location ? _getHashParam(window.location.hash, '__posthog') || _getHashParam(location.hash, 'state') : null @@ -40,10 +40,16 @@ export class Toolbar { * 2. From session storage under the key `toolbarParams` if the toolbar was initialized on a previous page */ maybeLoadToolbar( - location = window.location, + location: Location | undefined = undefined, localStorage: Storage | undefined = undefined, - history = window.history + history: History | undefined = undefined ): boolean { + if (!window || !document) { + return false + } + location = location ?? window.location + history = history ?? window.history + try { // Before running the code we check if we can access localStorage, if not we opt-out if (!localStorage) { @@ -55,7 +61,7 @@ export class Toolbar { } // If localStorage was undefined, and localStorage is supported we set the default value - localStorage = window.localStorage + localStorage = window?.localStorage } /** @@ -114,11 +120,11 @@ export class Toolbar { } loadToolbar(params?: ToolbarParams): boolean { - if ((window as any)['_postHogToolbarLoaded']) { + if (!window || assignableWindow['_postHogToolbarLoaded']) { return false } // only load the toolbar once, even if there are multiple instances of PostHogLib - ;(window as any)['_postHogToolbarLoaded'] = true + assignableWindow['_postHogToolbarLoaded'] = true const host = this.instance.config.api_host // toolbar.js is served from the PostHog CDN, this has a TTL of 24 hours. @@ -146,12 +152,12 @@ export class Toolbar { logger.error('Failed to load toolbar', err) return } - ;((window as any)['ph_load_toolbar'] || (window as any)['ph_load_editor'])(toolbarParams, this.instance) + ;(assignableWindow['ph_load_toolbar'] || assignableWindow['ph_load_editor'])(toolbarParams, this.instance) }) // Turbolinks doesn't fire an onload event but does replace the entire body, including the toolbar. // Thus, we ensure the toolbar is only loaded inside the body, and then reloaded on turbolinks:load. _register_event(window, 'turbolinks:load', () => { - ;(window as any)['_postHogToolbarLoaded'] = false + assignableWindow['_postHogToolbarLoaded'] = false this.loadToolbar(toolbarParams) }) return true @@ -164,9 +170,9 @@ export class Toolbar { /** @deprecated Use "maybeLoadToolbar" instead. */ maybeLoadEditor( - location = window.location, + location: Location | undefined = undefined, localStorage: Storage | undefined = undefined, - history = window.history + history: History | undefined = undefined ): boolean { return this.maybeLoadToolbar(location, localStorage, history) } diff --git a/src/gdpr-utils.ts b/src/gdpr-utils.ts index 1904d3d58..d175cfa3f 100644 --- a/src/gdpr-utils.ts +++ b/src/gdpr-utils.ts @@ -158,11 +158,11 @@ function _getStorageValue(token: string, options: GDPROptions) { function _hasDoNotTrackFlagOn(options: GDPROptions) { if (options && options.respectDnt) { const win = (options && options.window) || window - const nav = win['navigator'] || {} + const nav = win?.navigator let hasDntOn = false _each( [ - nav['doNotTrack'], // standard + nav?.doNotTrack, // standard (nav as any)['msDoNotTrack'], (win as any)['doNotTrack'], ], diff --git a/src/loader-exception-autocapture.ts b/src/loader-exception-autocapture.ts index c46ca95e1..8d50b19df 100644 --- a/src/loader-exception-autocapture.ts +++ b/src/loader-exception-autocapture.ts @@ -1,9 +1,9 @@ import { extendPostHog } from './extensions/exception-autocapture' -import { _isUndefined } from './utils/type-utils' +import { window } from './utils/globals' -const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window - -;(win as any).extendPostHogWithExceptionAutoCapture = extendPostHog +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.extendPostHogWithExceptionAutoCapture = extendPostHog export default extendPostHog diff --git a/src/loader-recorder-v2.ts b/src/loader-recorder-v2.ts index 598a991a2..4d99c3c40 100644 --- a/src/loader-recorder-v2.ts +++ b/src/loader-recorder-v2.ts @@ -23,6 +23,7 @@ import type { IWindow, listenerHandler, RecordPlugin } from '@rrweb/types' import { InitiatorType, NetworkRecordOptions, NetworkRequest, Headers } from './types' import { _isBoolean, _isFunction, _isArray, _isUndefined, _isNull } from './utils/type-utils' import { logger } from './utils/logger' +import { window } from './utils/globals' import { defaultNetworkOptions } from './extensions/replay/config' export type NetworkData = { @@ -454,10 +455,10 @@ export const getRecordNetworkPlugin: (options?: NetworkRecordOptions) => RecordP // rrweb/networ@1 ends -const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window - -;(win as any).rrweb = { record: rrwebRecord, version: 'v2', rrwebVersion: version } -;(win as any).rrwebConsoleRecord = { getRecordConsolePlugin } -;(win as any).getRecordNetworkPlugin = getRecordNetworkPlugin +if (window) { + ;(window as any).rrweb = { record: rrwebRecord, version: 'v2', rrwebVersion: version } + ;(window as any).rrwebConsoleRecord = { getRecordConsolePlugin } + ;(window as any).getRecordNetworkPlugin = getRecordNetworkPlugin +} export default rrwebRecord diff --git a/src/loader-recorder.ts b/src/loader-recorder.ts index 7ac507e79..2680a5358 100644 --- a/src/loader-recorder.ts +++ b/src/loader-recorder.ts @@ -8,13 +8,11 @@ import rrwebRecord from 'rrweb-v1/es/rrweb/packages/rrweb/src/record' // @ts-ignore import { getRecordConsolePlugin } from 'rrweb-v1/es/rrweb/packages/rrweb/src/plugins/console/record' -import { _isUndefined } from './utils/type-utils' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore - -const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window +import { window } from './utils/globals' -;(win as any).rrweb = { record: rrwebRecord, version: 'v1', rrwebVersion: version } -;(win as any).rrwebConsoleRecord = { getRecordConsolePlugin } +if (window) { + ;(window as any).rrweb = { record: rrwebRecord, version: 'v1', rrwebVersion: version } + ;(window as any).rrwebConsoleRecord = { getRecordConsolePlugin } +} export default rrwebRecord diff --git a/src/loader-surveys.ts b/src/loader-surveys.ts index 4925804f1..9e7bf8e1f 100644 --- a/src/loader-surveys.ts +++ b/src/loader-surveys.ts @@ -1,9 +1,9 @@ import { generateSurveys } from './extensions/surveys' -import { _isUndefined } from './utils/type-utils' +import { window } from './utils/globals' -const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window - -;(win as any).extendPostHogWithSurveys = generateSurveys +if (window) { + ;(window as any).extendPostHogWithSurveys = generateSurveys +} export default generateSurveys diff --git a/src/page-view.ts b/src/page-view.ts index 35de57395..6abb7f1a2 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -35,7 +35,7 @@ export class PageViewManager { _createPageViewData(): PageViewData { return { - pathname: window.location.pathname, + pathname: window?.location.pathname ?? '', } } @@ -134,31 +134,33 @@ export class PageViewManager { } startMeasuringScrollPosition() { - window.addEventListener('scroll', this._updateScrollData) - window.addEventListener('scrollend', this._updateScrollData) - window.addEventListener('resize', this._updateScrollData) + window?.addEventListener('scroll', this._updateScrollData) + window?.addEventListener('scrollend', this._updateScrollData) + window?.addEventListener('resize', this._updateScrollData) } stopMeasuringScrollPosition() { - window.removeEventListener('scroll', this._updateScrollData) - window.removeEventListener('scrollend', this._updateScrollData) - window.removeEventListener('resize', this._updateScrollData) + window?.removeEventListener('scroll', this._updateScrollData) + window?.removeEventListener('scrollend', this._updateScrollData) + window?.removeEventListener('resize', this._updateScrollData) } _scrollHeight(): number { - return Math.max(0, window.document.documentElement.scrollHeight - window.document.documentElement.clientHeight) + return window + ? Math.max(0, window.document.documentElement.scrollHeight - window.document.documentElement.clientHeight) + : 0 } _scrollY(): number { - return window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 + return window ? window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 : 0 } _contentHeight(): number { - return window.document.documentElement.scrollHeight || 0 + return window?.document.documentElement.scrollHeight || 0 } _contentY(): number { - const clientHeight = window.document.documentElement.clientHeight || 0 + const clientHeight = window?.document.documentElement.clientHeight || 0 return this._scrollY() + clientHeight } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 7a844ed3d..cbc1e0fc1 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -9,7 +9,7 @@ import { _safewrap_class, isCrossDomainCookie, } from './utils' -import { window } from './utils/globals' +import { assignableWindow, window } from './utils/globals' import { autocapture } from './autocapture' import { PostHogFeatureFlags } from './posthog-featureflags' import { PostHogPersistence } from './posthog-persistence' @@ -92,12 +92,12 @@ 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() +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 = !USE_XHR && userAgent?.indexOf('MSIE') === -1 && userAgent?.indexOf('Mozilla') === -1 export const defaultConfig = (): PostHogConfig => ({ api_host: 'https://app.posthog.com', @@ -514,8 +514,7 @@ export class PostHog { } // Set up event handler for pageleave // Use `onpagehide` if available, see https://calendar.perfplanet.com/2020/beaconing-in-practice/#beaconing-reliability-avoiding-abandons - window.addEventListener && - window.addEventListener('onpagehide' in self ? 'pagehide' : 'unload', this._handle_unload.bind(this)) + window?.addEventListener?.('onpagehide' in self ? 'pagehide' : 'unload', this._handle_unload.bind(this)) // Make sure that we also call the initComplete callback at the end of // the synchronous code as well. @@ -558,7 +557,7 @@ export class PostHog { // this happens after so a user can call identify in // the loaded callback - if (this.config.capture_pageview) { + if (this.config.capture_pageview && document) { this.capture('$pageview', { title: document.title }, { send_instantly: true }) } @@ -683,7 +682,7 @@ export class PostHog { options.method = 'GET' } - const useSendBeacon = 'sendBeacon' in window.navigator && options.transport === 'sendBeacon' + const useSendBeacon = window && 'sendBeacon' in window.navigator && options.transport === 'sendBeacon' url = addParamsToURL(url, options.urlQueryArgs || {}, { ip: this.config.ip, }) @@ -692,12 +691,12 @@ export class PostHog { // beacon documentation https://w3c.github.io/beacon/ // beacons format the message and use the type property try { - window.navigator.sendBeacon(url, encodePostData(data, { ...options, sendBeacon: true })) + window?.navigator.sendBeacon(url, encodePostData(data, { ...options, sendBeacon: true })) } catch (e) { // 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) { + } else if (USE_XHR || !document) { try { xhr({ url: url, @@ -851,7 +850,7 @@ export class PostHog { return } - if (_isBlockedUA(userAgent, this.config.custom_blocked_useragents)) { + if (userAgent && _isBlockedUA(userAgent, this.config.custom_blocked_useragents)) { return } @@ -956,7 +955,7 @@ export class PostHog { properties = _extend(properties, performanceProperties) } - if (event_name === '$pageview') { + if (event_name === '$pageview' && document) { properties['title'] = document.title } @@ -2045,11 +2044,11 @@ export class PostHog { debug(debug?: boolean): void { if (debug === false) { - window.console.log("You've disabled debug mode.") + window?.console.log("You've disabled debug mode.") localStorage && localStorage.removeItem('ph_debug') this.set_config({ debug: false }) } else { - window.console.log( + window?.console.log( "You're now in debug mode. All calls to PostHog will be logged in your console.\nYou can disable this with `posthog.debug(false)`." ) localStorage && localStorage.setItem('ph_debug', 'true') @@ -2105,7 +2104,7 @@ const override_ph_init_func = function () { ;(posthog_master as any) = instance if (init_type === InitType.INIT_SNIPPET) { - ;(window as any)[PRIMARY_INSTANCE_NAME] = posthog_master + assignableWindow[PRIMARY_INSTANCE_NAME] = posthog_master } extend_mp() return instance @@ -2129,7 +2128,7 @@ const add_dom_loaded_handler = function () { }) } - if (document.addEventListener) { + if (document?.addEventListener) { if (document.readyState === 'complete') { // safari 4 can fire the DOMContentLoaded event before loading all // external JS (including this file). you will see some copypasta @@ -2142,15 +2141,17 @@ const add_dom_loaded_handler = function () { } // fallback handler, always will work - _register_event(window, 'load', dom_loaded_handler, true) + if (window) { + _register_event(window, 'load', dom_loaded_handler, true) + } } export function init_from_snippet(): void { init_type = InitType.INIT_SNIPPET - if (_isUndefined((window as any).posthog)) { - ;(window as any).posthog = [] + if (_isUndefined(assignableWindow.posthog)) { + assignableWindow.posthog = [] } - posthog_master = (window as any).posthog + posthog_master = assignableWindow.posthog if (posthog_master['__loaded'] || (posthog_master['config'] && posthog_master['persistence'])) { // lib has already been loaded at least once; we don't want to override the global object this time so bomb early diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts index 730b8d25d..3ac9de094 100644 --- a/src/posthog-surveys.ts +++ b/src/posthog-surveys.ts @@ -2,11 +2,13 @@ import { PostHog } from './posthog-core' import { SURVEYS } from './constants' import { SurveyCallback, SurveyUrlMatchType } from './posthog-surveys-types' import { _isUrlMatchingRegex } from './utils/request-utils' +import { window, document } from './utils/globals' export const surveyUrlValidationMap: Record boolean> = { - icontains: (conditionsUrl) => window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1, - regex: (conditionsUrl) => _isUrlMatchingRegex(window.location.href, conditionsUrl), - exact: (conditionsUrl) => window.location.href === conditionsUrl, + icontains: (conditionsUrl) => + !!window && window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1, + regex: (conditionsUrl) => !!window && _isUrlMatchingRegex(window.location.href, conditionsUrl), + exact: (conditionsUrl) => window?.location.href === conditionsUrl, } export class PostHogSurveys { @@ -49,7 +51,7 @@ export class PostHogSurveys { ? surveyUrlValidationMap[survey.conditions?.urlMatchType ?? 'icontains'](survey.conditions.url) : true const selectorCheck = survey.conditions?.selector - ? document.querySelector(survey.conditions.selector) + ? document?.querySelector(survey.conditions.selector) : true return urlCheck && selectorCheck }) diff --git a/src/retry-queue.ts b/src/retry-queue.ts index f848e2a96..bea0c7c69 100644 --- a/src/retry-queue.ts +++ b/src/retry-queue.ts @@ -5,6 +5,7 @@ import { RateLimiter } from './rate-limiter' import { _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window } from './utils/globals' const thirtyMinutes = 30 * 60 * 1000 @@ -114,7 +115,7 @@ export class RetryQueue extends RequestQueueScaffold { try { // we've had send beacon in place for at least 2 years // eslint-disable-next-line compat/compat - window.navigator.sendBeacon(url, encodePostData(data, { ...options, sendBeacon: true })) + window?.navigator.sendBeacon(url, encodePostData(data, { ...options, sendBeacon: true })) } catch (e) { // Note sendBeacon automatically retries, and after the first retry it will lose reference to contextual `this`. // This means in some cases `this.getConfig` will be undefined. diff --git a/src/session-props.ts b/src/session-props.ts index 15f0d2725..79021ae80 100644 --- a/src/session-props.ts +++ b/src/session-props.ts @@ -27,9 +27,9 @@ interface StoredSessionSourceProps { props: SessionSourceProps } -export const generateSessionSourceParams = (): SessionSourceProps => { +const generateSessionSourceParams = (): SessionSourceProps => { return { - initialPathName: window.location.pathname, + initialPathName: window?.location.pathname || '', referringDomain: _info.referringDomain(), ..._info.campaignParams(), } @@ -38,12 +38,12 @@ export const generateSessionSourceParams = (): SessionSourceProps => { export class SessionPropsManager { private readonly _sessionIdManager: SessionIdManager private readonly _persistence: PostHogPersistence - private readonly _sessionSourceParamGenerator: typeof generateSessionSourceParams + private readonly _sessionSourceParamGenerator: () => SessionSourceProps constructor( sessionIdManager: SessionIdManager, persistence: PostHogPersistence, - sessionSourceParamGenerator?: typeof generateSessionSourceParams + sessionSourceParamGenerator?: () => SessionSourceProps ) { this._sessionIdManager = sessionIdManager this._persistence = persistence diff --git a/src/sessionid.ts b/src/sessionid.ts index fca582a16..555a8f779 100644 --- a/src/sessionid.ts +++ b/src/sessionid.ts @@ -173,7 +173,7 @@ export class SessionIdManager { * We conditionally check the primaryWindowExists value in the constructor to decide if the window id in the last session storage should be carried over. */ private _listenToReloadWindow(): void { - window.addEventListener('beforeunload', () => { + window?.addEventListener('beforeunload', () => { if (this._canUseSessionStorage()) { sessionStore.remove(this._primary_window_exists_storage_key) } diff --git a/src/storage.ts b/src/storage.ts index 375555dbe..c559b379c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -4,6 +4,7 @@ import { DISTINCT_ID, SESSION_ID, SESSION_RECORDING_IS_SAMPLED } from './constan import { _isNull, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { window, document } from './utils/globals' import { uuidv7 } from './uuidv7' const Y1970 = 'Thu, 01 Jan 1970 00:00:00 GMT' @@ -22,6 +23,9 @@ const Y1970 = 'Thu, 01 Jan 1970 00:00:00 GMT' * inspired by https://github.com/AngusFu/browser-root-domain */ export function seekFirstNonPublicSubDomain(hostname: string, cookieJar = document): string { + if (!cookieJar) { + return '' + } if (['localhost', '127.0.0.1'].includes(hostname)) return '' const list = hostname.split('.') @@ -71,13 +75,17 @@ export function chooseCookieDomain(hostname: string, cross_subdomain: boolean | // Methods partially borrowed from quirksmode.org/js/cookies.html export const cookieStore: PersistentStore = { - is_supported: () => true, + is_supported: () => !!document, error: function (msg) { logger.error('cookieStore error: ' + msg) }, get: function (name) { + if (!document) { + return + } + try { const nameEQ = name + '=' const ca = document.cookie.split(';').filter((x) => x.length) @@ -105,6 +113,9 @@ export const cookieStore: PersistentStore = { }, set: function (name, value, days, cross_subdomain, is_secure) { + if (!document) { + return + } try { let expires = '', secure = '' @@ -183,7 +194,7 @@ export const localStore: PersistentStore = { get: function (name) { try { - return window.localStorage.getItem(name) + return window?.localStorage.getItem(name) } catch (err) { localStore.error(err) } @@ -201,7 +212,7 @@ export const localStore: PersistentStore = { set: function (name, value) { try { - window.localStorage.setItem(name, JSON.stringify(value)) + window?.localStorage.setItem(name, JSON.stringify(value)) } catch (err) { localStore.error(err) } @@ -209,7 +220,7 @@ export const localStore: PersistentStore = { remove: function (name) { try { - window.localStorage.removeItem(name) + window?.localStorage.removeItem(name) } catch (err) { localStore.error(err) } @@ -259,7 +270,7 @@ export const localPlusCookieStore: PersistentStore = { remove: function (name, cross_subdomain) { try { - window.localStorage.removeItem(name) + window?.localStorage.removeItem(name) cookieStore.remove(name, cross_subdomain) } catch (err) { localStore.error(err) @@ -331,7 +342,7 @@ export const sessionStore: PersistentStore = { get: function (name) { try { - return window.sessionStorage.getItem(name) + return window?.sessionStorage.getItem(name) } catch (err) { sessionStore.error(err) } @@ -349,7 +360,7 @@ export const sessionStore: PersistentStore = { set: function (name, value) { try { - window.sessionStorage.setItem(name, JSON.stringify(value)) + window?.sessionStorage.setItem(name, JSON.stringify(value)) } catch (err) { sessionStore.error(err) } @@ -357,7 +368,7 @@ export const sessionStore: PersistentStore = { remove: function (name) { try { - window.sessionStorage.removeItem(name) + window?.sessionStorage.removeItem(name) } catch (err) { sessionStore.error(err) } diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index b3a629d57..6e202a678 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -3,7 +3,7 @@ import { _isNull, _isUndefined } from './type-utils' import { Properties } from '../types' import Config from '../config' import { _each, _extend, _includes, _strip_empty_properties, _timestamp } from './index' -import { document, userAgent } from './globals' +import { document, window, userAgent, assignableWindow } from './globals' /** * Safari detection turns out to be complicted. For e.g. https://stackoverflow.com/a/29696509 @@ -29,7 +29,7 @@ export const _info = { const params: Record = {} _each(campaign_keywords, function (kwkey) { - const kw = _getQueryParam(document.URL, kwkey) + const kw = document ? _getQueryParam(document.URL, kwkey) : '' if (kw.length) { params[kwkey] = kw } @@ -39,7 +39,7 @@ export const _info = { }, searchEngine: function (): string | null { - const referrer = document.referrer + const referrer = document?.referrer if (!referrer) { return null } else if (referrer.search('https?://(.*)google.([^/?]*)') === 0) { @@ -63,7 +63,7 @@ export const _info = { if (!_isNull(search)) { ret['$search_engine'] = search - const keyword = _getQueryParam(document.referrer, param) + const keyword = document ? _getQueryParam(document.referrer, param) : '' if (keyword.length) { ret['ph_keyword'] = keyword } @@ -249,11 +249,11 @@ export const _info = { }, referrer: function (): string { - return document.referrer || '$direct' + return document?.referrer || '$direct' }, referringDomain: function (): string { - if (!document.referrer) { + if (!document?.referrer) { return '$direct' } const parser = document.createElement('a') // Unfortunately we cannot use new URL due to IE11 @@ -262,12 +262,15 @@ export const _info = { }, properties: function (): Properties { + if (!userAgent) { + return {} + } const { os_name, os_version } = _info.os(userAgent) return _extend( _strip_empty_properties({ $os: os_name, $os_version: os_version, - $browser: _info.browser(userAgent, navigator.vendor, (window as any).opera), + $browser: _info.browser(userAgent, navigator.vendor, assignableWindow.opera), $device: _info.device(userAgent), $device_type: _info.deviceType(userAgent), }), @@ -276,7 +279,7 @@ export const _info = { $host: window?.location.host, $pathname: window?.location.pathname, $raw_user_agent: userAgent.length > 1000 ? userAgent.substring(0, 997) + '...' : userAgent, - $browser_version: _info.browserVersion(userAgent, navigator.vendor, (window as any).opera), + $browser_version: _info.browserVersion(userAgent, navigator.vendor, assignableWindow.opera), $browser_language: _info.browserLanguage(), $screen_height: window?.screen.height, $screen_width: window?.screen.width, @@ -291,15 +294,19 @@ export const _info = { }, people_properties: function (): Properties { + if (!userAgent) { + return {} + } + const { os_name, os_version } = _info.os(userAgent) return _extend( _strip_empty_properties({ $os: os_name, $os_version: os_version, - $browser: _info.browser(userAgent, navigator.vendor, (window as any).opera), + $browser: _info.browser(userAgent, navigator.vendor, assignableWindow.opera), }), { - $browser_version: _info.browserVersion(userAgent, navigator.vendor, (window as any).opera), + $browser_version: _info.browserVersion(userAgent, navigator.vendor, assignableWindow.opera), } ) }, diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 42c3f6800..d90d41708 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -4,9 +4,11 @@ export const ArrayProto = Array.prototype export const nativeForEach = ArrayProto.forEach export const nativeIndexOf = ArrayProto.indexOf -export const win: Window & typeof globalThis = typeof window !== 'undefined' ? window : ({} as typeof window) -const navigator = win.navigator || { userAgent: '' } -const document = win.document || {} -const userAgent = navigator.userAgent +// eslint-disable-next-line no-restricted-globals +export const win: (Window & typeof globalThis) | undefined = typeof window !== 'undefined' ? window : undefined +const navigator = win?.navigator +export const document = win?.document +export const userAgent = navigator?.userAgent +export const assignableWindow: Window & typeof globalThis & Record = win ?? ({} as any) -export { win as window, userAgent, document } +export { win as window } diff --git a/src/utils/index.ts b/src/utils/index.ts index 99a5d237e..eff109934 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,7 +10,7 @@ import { hasOwnProperty, } from './type-utils' import { logger } from './logger' -import { document, nativeForEach, nativeIndexOf } from './globals' +import { window, document, nativeForEach, nativeIndexOf } from './globals' const breaker: Breaker = {} @@ -466,7 +466,7 @@ export const _register_event = (function () { old_handlers: EventHandler ) { return function (event: Event): boolean | void { - event = event || fixEvent(window.event) + event = event || fixEvent(window?.event) // this basically happens in firefox whenever another script // overwrites the onload callback and doesn't pass the event @@ -512,6 +512,9 @@ export const _register_event = (function () { export function loadScript(scriptUrlToLoad: string, callback: (error?: string | Event, event?: Event) => void): void { const addScript = () => { + if (!document) { + return callback('document not found') + } const scriptTag = document.createElement('script') scriptTag.type = 'text/javascript' scriptTag.src = scriptUrlToLoad @@ -527,10 +530,10 @@ export function loadScript(scriptUrlToLoad: string, callback: (error?: string | } } - if (document.body) { + if (document?.body) { addScript() } else { - document.addEventListener('DOMContentLoaded', addScript) + document?.addEventListener('DOMContentLoaded', addScript) } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 48a52f89e..6bc11befb 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,11 +1,16 @@ import Config from '../config' import { _isUndefined } from './type-utils' -import { window } from './globals' +import { assignableWindow, window } from './globals' const LOGGER_PREFIX = '[PostHog.js]' export const logger = { _log: (level: 'log' | 'warn' | 'error', ...args: any[]) => { - if ((Config.DEBUG || (window as any).POSTHOG_DEBUG) && !_isUndefined(window.console) && window.console) { + if ( + window && + (Config.DEBUG || assignableWindow.POSTHOG_DEBUG) && + !_isUndefined(window.console) && + window.console + ) { const consoleLog = '__rrweb_original__' in window.console[level] ? (window.console[level] as any)['__rrweb_original__'] diff --git a/src/uuidv7.ts b/src/uuidv7.ts index c1d6e3bb9..032b5546f 100644 --- a/src/uuidv7.ts +++ b/src/uuidv7.ts @@ -219,7 +219,7 @@ let getRandomValues: (buffer: T) => T = (buf } // detect Web Crypto API -if (!_isUndefined(window.crypto) && crypto.getRandomValues) { +if (window && !_isUndefined(window.crypto) && crypto.getRandomValues) { getRandomValues = (buffer) => crypto.getRandomValues(buffer) }