@leanbase.com/js 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { addEventListener, each, extend } from './utils'
2
+ import {
3
+ autocaptureCompatibleElements,
4
+ getClassNames,
5
+ getDirectAndNestedSpanText,
6
+ getElementsChainString,
7
+ getEventTarget,
8
+ getSafeText,
9
+ isAngularStyleAttr,
10
+ isSensitiveElement,
11
+ makeSafeText,
12
+ shouldCaptureDomEvent,
13
+ shouldCaptureElement,
14
+ shouldCaptureRageclick,
15
+ shouldCaptureValue,
16
+ splitClassString,
17
+ } from './autocapture-utils'
18
+
19
+ import RageClick from './extensions/rageclick'
20
+ import { AutocaptureConfig, COPY_AUTOCAPTURE_EVENT, EventName, Properties, RemoteConfig } from './types'
21
+ import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants'
22
+
23
+ import { isBoolean, isFunction, isNull, isObject } from '@posthog/core'
24
+ import { document, window } from './utils'
25
+ import { convertToURL } from './utils/request-utils'
26
+ import { isDocumentFragment, isElementNode, isTag, isTextNode } from './utils/element-utils'
27
+ import { includes } from '@posthog/core'
28
+ import { logger } from './leanbase-logger'
29
+ import { Leanbase } from './leanbase'
30
+
31
+ function limitText(length: number, text: string): string {
32
+ if (text.length > length) {
33
+ return text.slice(0, length) + '...'
34
+ }
35
+ return text
36
+ }
37
+
38
+ export function getAugmentPropertiesFromElement(elem: Element): Properties {
39
+ const shouldCaptureEl = shouldCaptureElement(elem)
40
+ if (!shouldCaptureEl) {
41
+ return {}
42
+ }
43
+
44
+ const props: Properties = {}
45
+
46
+ each(elem.attributes, function (attr: Attr) {
47
+ if (attr.name && attr.name.indexOf('data-ph-capture-attribute') === 0) {
48
+ const propertyKey = attr.name.replace('data-ph-capture-attribute-', '')
49
+ const propertyValue = attr.value
50
+ if (propertyKey && propertyValue && shouldCaptureValue(propertyValue)) {
51
+ props[propertyKey] = propertyValue
52
+ }
53
+ }
54
+ })
55
+
56
+ return props
57
+ }
58
+
59
+ export function previousElementSibling(el: Element): Element | null {
60
+ if (el.previousElementSibling) {
61
+ return el.previousElementSibling
62
+ }
63
+ let _el: Element | null = el
64
+ do {
65
+ _el = _el.previousSibling as Element | null // resolves to ChildNode->Node, which is Element's parent class
66
+ } while (_el && !isElementNode(_el))
67
+ return _el
68
+ }
69
+
70
+ export function getDefaultProperties(eventType: string): Properties {
71
+ return {
72
+ $event_type: eventType,
73
+ $ce_version: 1,
74
+ }
75
+ }
76
+
77
+ export function getPropertiesFromElement(
78
+ elem: Element,
79
+ maskAllAttributes: boolean,
80
+ maskText: boolean,
81
+ elementAttributeIgnorelist: string[] | undefined
82
+ ): Properties {
83
+ const tag_name = elem.tagName.toLowerCase()
84
+ const props: Properties = {
85
+ tag_name: tag_name,
86
+ }
87
+ if (autocaptureCompatibleElements.indexOf(tag_name) > -1 && !maskText) {
88
+ if (tag_name.toLowerCase() === 'a' || tag_name.toLowerCase() === 'button') {
89
+ props['$el_text'] = limitText(1024, getDirectAndNestedSpanText(elem))
90
+ } else {
91
+ props['$el_text'] = limitText(1024, getSafeText(elem))
92
+ }
93
+ }
94
+
95
+ const classes = getClassNames(elem)
96
+ if (classes.length > 0)
97
+ props['classes'] = classes.filter(function (c) {
98
+ return c !== ''
99
+ })
100
+
101
+ // capture the deny list here because this not-a-class class makes it tricky to use this.config in the function below
102
+ each(elem.attributes, function (attr: Attr) {
103
+ // Only capture attributes we know are safe
104
+ if (isSensitiveElement(elem) && ['name', 'id', 'class', 'aria-label'].indexOf(attr.name) === -1) return
105
+
106
+ if (elementAttributeIgnorelist?.includes(attr.name)) return
107
+
108
+ if (!maskAllAttributes && shouldCaptureValue(attr.value) && !isAngularStyleAttr(attr.name)) {
109
+ let value = attr.value
110
+ if (attr.name === 'class') {
111
+ // html attributes can _technically_ contain linebreaks,
112
+ // but we're very intolerant of them in the class string,
113
+ // so we strip them.
114
+ value = splitClassString(value).join(' ')
115
+ }
116
+ props['attr__' + attr.name] = limitText(1024, value)
117
+ }
118
+ })
119
+
120
+ let nthChild = 1
121
+ let nthOfType = 1
122
+ let currentElem: Element | null = elem
123
+ while ((currentElem = previousElementSibling(currentElem))) {
124
+ // eslint-disable-line no-cond-assign
125
+ nthChild++
126
+ if (currentElem.tagName === elem.tagName) {
127
+ nthOfType++
128
+ }
129
+ }
130
+ props['nth_child'] = nthChild
131
+ props['nth_of_type'] = nthOfType
132
+
133
+ return props
134
+ }
135
+
136
+ export function autocapturePropertiesForElement(
137
+ target: Element,
138
+ {
139
+ e,
140
+ maskAllElementAttributes,
141
+ maskAllText,
142
+ elementAttributeIgnoreList,
143
+ elementsChainAsString,
144
+ }: {
145
+ e: Event
146
+ maskAllElementAttributes: boolean
147
+ maskAllText: boolean
148
+ elementAttributeIgnoreList?: string[] | undefined
149
+ elementsChainAsString: boolean
150
+ }
151
+ ): { props: Properties; explicitNoCapture?: boolean } {
152
+ const targetElementList = [target]
153
+ let curEl = target
154
+ while (curEl.parentNode && !isTag(curEl, 'body')) {
155
+ if (isDocumentFragment(curEl.parentNode)) {
156
+ targetElementList.push((curEl.parentNode as any).host)
157
+ curEl = (curEl.parentNode as any).host
158
+ continue
159
+ }
160
+ targetElementList.push(curEl.parentNode as Element)
161
+ curEl = curEl.parentNode as Element
162
+ }
163
+
164
+ const elementsJson: Properties[] = []
165
+ const autocaptureAugmentProperties: Properties = {}
166
+ let href: string | false = false
167
+ let explicitNoCapture = false
168
+
169
+ each(targetElementList, (el) => {
170
+ const shouldCaptureEl = shouldCaptureElement(el)
171
+
172
+ // if the element or a parent element is an anchor tag
173
+ // include the href as a property
174
+ if (el.tagName.toLowerCase() === 'a') {
175
+ href = el.getAttribute('href')
176
+ href = shouldCaptureEl && href && shouldCaptureValue(href) && href
177
+ }
178
+
179
+ // allow users to programmatically prevent capturing of elements by adding class 'ph-no-capture'
180
+ const classes = getClassNames(el)
181
+ if (includes(classes, 'ph-no-capture')) {
182
+ explicitNoCapture = true
183
+ }
184
+
185
+ elementsJson.push(
186
+ getPropertiesFromElement(el, maskAllElementAttributes, maskAllText, elementAttributeIgnoreList)
187
+ )
188
+
189
+ const augmentProperties = getAugmentPropertiesFromElement(el)
190
+ extend(autocaptureAugmentProperties, augmentProperties)
191
+ })
192
+
193
+ if (explicitNoCapture) {
194
+ return { props: {}, explicitNoCapture }
195
+ }
196
+
197
+ if (!maskAllText) {
198
+ // if the element is a button or anchor tag get the span text from any
199
+ // children and include it as/with the text property on the parent element
200
+ if (target.tagName.toLowerCase() === 'a' || target.tagName.toLowerCase() === 'button') {
201
+ elementsJson[0]['$el_text'] = getDirectAndNestedSpanText(target)
202
+ } else {
203
+ elementsJson[0]['$el_text'] = getSafeText(target)
204
+ }
205
+ }
206
+
207
+ let externalHref: string | undefined
208
+ if (href) {
209
+ elementsJson[0]['attr__href'] = href
210
+ const hrefHost = convertToURL(href)?.host
211
+ const locationHost = window?.location?.host
212
+ if (hrefHost && locationHost && hrefHost !== locationHost) {
213
+ externalHref = href
214
+ }
215
+ }
216
+
217
+ const props = extend(
218
+ getDefaultProperties(e.type),
219
+ // Sending "$elements" is deprecated. Only one client on US cloud uses this.
220
+ !elementsChainAsString ? { $elements: elementsJson } : {},
221
+ // Always send $elements_chain, as it's needed downstream in site app filtering
222
+ { $elements_chain: getElementsChainString(elementsJson) },
223
+ elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {},
224
+ externalHref && e.type === 'click' ? { $external_click_url: externalHref } : {},
225
+ autocaptureAugmentProperties
226
+ )
227
+
228
+ return { props }
229
+ }
230
+
231
+ export class Autocapture {
232
+ instance: Leanbase
233
+ _initialized: boolean = false
234
+ _isDisabledServerSide: boolean | null = null
235
+ _elementSelectors: Set<string> | null
236
+ rageclicks = new RageClick()
237
+ _elementsChainAsString = false
238
+
239
+ constructor(instance: Leanbase) {
240
+ this.instance = instance
241
+ this._elementSelectors = null
242
+ }
243
+
244
+ private get _config(): AutocaptureConfig {
245
+ const config = isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {}
246
+ // precompile the regex
247
+ config.url_allowlist = config.url_allowlist?.map((url) => new RegExp(url))
248
+ config.url_ignorelist = config.url_ignorelist?.map((url) => new RegExp(url))
249
+ return config
250
+ }
251
+
252
+ _addDomEventHandlers(): void {
253
+ if (!this.isBrowserSupported()) {
254
+ logger.info('Disabling Automatic Event Collection because this browser is not supported')
255
+ return
256
+ }
257
+
258
+ if (!window || !document) {
259
+ return
260
+ }
261
+
262
+ const handler = (e: Event) => {
263
+ e = e || window?.event
264
+ try {
265
+ this._captureEvent(e)
266
+ } catch (error) {
267
+ logger.error('Failed to capture event', error)
268
+ }
269
+ }
270
+
271
+ addEventListener(document, 'submit', handler, { capture: true })
272
+ addEventListener(document, 'change', handler, { capture: true })
273
+ addEventListener(document, 'click', handler, { capture: true })
274
+
275
+ if (this._config.capture_copied_text) {
276
+ const copiedTextHandler = (e: Event) => {
277
+ e = e || window?.event
278
+ this._captureEvent(e, COPY_AUTOCAPTURE_EVENT)
279
+ }
280
+
281
+ addEventListener(document, 'copy', copiedTextHandler, { capture: true })
282
+ addEventListener(document, 'cut', copiedTextHandler, { capture: true })
283
+ }
284
+ }
285
+
286
+ public startIfEnabled() {
287
+ if (this.isEnabled && !this._initialized) {
288
+ this._addDomEventHandlers()
289
+ this._initialized = true
290
+ }
291
+ }
292
+
293
+ public onRemoteConfig(response: RemoteConfig) {
294
+ if (response.elementsChainAsString) {
295
+ this._elementsChainAsString = response.elementsChainAsString
296
+ }
297
+
298
+ if (this.instance.persistence) {
299
+ this.instance.persistence.register({
300
+ [AUTOCAPTURE_DISABLED_SERVER_SIDE]: !!response['autocapture_opt_out'],
301
+ })
302
+ }
303
+
304
+ this._isDisabledServerSide = !!response['autocapture_opt_out']
305
+ this.startIfEnabled()
306
+ }
307
+
308
+ public setElementSelectors(selectors: Set<string>): void {
309
+ this._elementSelectors = selectors
310
+ }
311
+
312
+ public getElementSelectors(element: Element | null): string[] | null {
313
+ const elementSelectors: string[] = []
314
+
315
+ this._elementSelectors?.forEach((selector) => {
316
+ const matchedElements = document?.querySelectorAll(selector)
317
+ matchedElements?.forEach((matchedElement: Element) => {
318
+ if (element === matchedElement) {
319
+ elementSelectors.push(selector)
320
+ }
321
+ })
322
+ })
323
+
324
+ return elementSelectors
325
+ }
326
+
327
+ public get isEnabled(): boolean {
328
+ const persistedServerDisabled = this.instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE]
329
+ const memoryDisabled = this._isDisabledServerSide
330
+
331
+ if (isNull(memoryDisabled) && !isBoolean(persistedServerDisabled)) {
332
+ return false
333
+ }
334
+
335
+ const disabledServer = this._isDisabledServerSide ?? !!persistedServerDisabled
336
+ const disabledClient = !this.instance.config.autocapture
337
+ return !disabledClient && !disabledServer
338
+ }
339
+
340
+ private _captureEvent(e: Event, eventName: EventName = '$autocapture'): boolean | void {
341
+ if (!this.isEnabled) {
342
+ return
343
+ }
344
+
345
+ /*** Don't mess with this code without running IE8 tests on it ***/
346
+ let target = getEventTarget(e)
347
+ if (isTextNode(target)) {
348
+ // defeat Safari bug (see: http://www.quirksmode.org/js/events_properties.html)
349
+ target = (target.parentNode || null) as Element | null
350
+ }
351
+
352
+ if (eventName === '$autocapture' && e.type === 'click' && e instanceof MouseEvent) {
353
+ if (
354
+ !!this.instance.config.rageclick &&
355
+ this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())
356
+ ) {
357
+ if (shouldCaptureRageclick(target, this.instance.config.rageclick)) {
358
+ this._captureEvent(e, '$rageclick')
359
+ }
360
+ }
361
+ }
362
+
363
+ const isCopyAutocapture = eventName === COPY_AUTOCAPTURE_EVENT
364
+ if (
365
+ target &&
366
+ shouldCaptureDomEvent(
367
+ target,
368
+ e,
369
+ this._config,
370
+ // mostly this method cares about the target element, but in the case of copy events,
371
+ // we want some of the work this check does without insisting on the target element's type
372
+ isCopyAutocapture,
373
+ // we also don't want to restrict copy checks to clicks,
374
+ // so we pass that knowledge in here, rather than add the logic inside the check
375
+ isCopyAutocapture ? ['copy', 'cut'] : undefined
376
+ )
377
+ ) {
378
+ const { props, explicitNoCapture } = autocapturePropertiesForElement(target, {
379
+ e,
380
+ maskAllElementAttributes: this.instance.config.mask_all_element_attributes,
381
+ maskAllText: this.instance.config.mask_all_text,
382
+ elementAttributeIgnoreList: this._config.element_attribute_ignorelist,
383
+ elementsChainAsString: this._elementsChainAsString,
384
+ })
385
+
386
+ if (explicitNoCapture) {
387
+ return false
388
+ }
389
+
390
+ const elementSelectors = this.getElementSelectors(target)
391
+ if (elementSelectors && elementSelectors.length > 0) {
392
+ props['$element_selectors'] = elementSelectors
393
+ }
394
+
395
+ if (eventName === COPY_AUTOCAPTURE_EVENT) {
396
+ // you can't read the data from the clipboard event,
397
+ // but you can guess that you can read it from the window's current selection
398
+ const selectedContent = makeSafeText(window?.getSelection()?.toString())
399
+ const clipType = (e as ClipboardEvent).type || 'clipboard'
400
+ if (!selectedContent) {
401
+ return false
402
+ }
403
+ props['$selected_content'] = selectedContent
404
+ props['$copy_type'] = clipType
405
+ }
406
+
407
+ this.instance.capture(eventName, props)
408
+ return true
409
+ }
410
+ }
411
+
412
+ isBrowserSupported(): boolean {
413
+ return isFunction(document?.querySelectorAll)
414
+ }
415
+ }
package/src/config.ts ADDED
@@ -0,0 +1,8 @@
1
+ import packageInfo from '../package.json'
2
+
3
+ const Config = {
4
+ DEBUG: false,
5
+ LIB_VERSION: packageInfo.version,
6
+ }
7
+
8
+ export default Config
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Constants
3
+ */
4
+
5
+ import { PostHogPersistedProperty } from '@posthog/core'
6
+
7
+ /* PROPERTY KEYS */
8
+
9
+ // This key is deprecated, but we want to check for it to see whether aliasing is allowed.
10
+ export const PEOPLE_DISTINCT_ID_KEY = '$people_distinct_id'
11
+ export const DISTINCT_ID = 'distinct_id'
12
+ export const ALIAS_ID_KEY = '__alias'
13
+ export const CAMPAIGN_IDS_KEY = '__cmpns'
14
+ export const EVENT_TIMERS_KEY = '__timers'
15
+ export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_side'
16
+ export const HEATMAPS_ENABLED_SERVER_SIDE = '$heatmaps_enabled_server_side'
17
+ export const EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE = '$exception_capture_enabled_server_side'
18
+ export const ERROR_TRACKING_SUPPRESSION_RULES = '$error_tracking_suppression_rules'
19
+ export const ERROR_TRACKING_CAPTURE_EXTENSION_EXCEPTIONS = '$error_tracking_capture_extension_exceptions'
20
+ export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side'
21
+ export const DEAD_CLICKS_ENABLED_SERVER_SIDE = '$dead_clicks_enabled_server_side'
22
+ export const WEB_VITALS_ALLOWED_METRICS = '$web_vitals_allowed_metrics'
23
+ export const SESSION_RECORDING_REMOTE_CONFIG = '$session_recording_remote_config'
24
+ // @deprecated can be removed along with eager loaded replay
25
+ export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side'
26
+ // @deprecated can be removed along with eager loaded replay
27
+ export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side'
28
+ // @deprecated can be removed along with eager loaded replay
29
+ export const SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE = '$session_recording_network_payload_capture'
30
+ // @deprecated can be removed along with eager loaded replay
31
+ export const SESSION_RECORDING_MASKING = '$session_recording_masking'
32
+ // @deprecated can be removed along with eager loaded replay
33
+ export const SESSION_RECORDING_CANVAS_RECORDING = '$session_recording_canvas_recording'
34
+ // @deprecated can be removed along with eager loaded replay
35
+ export const SESSION_RECORDING_SAMPLE_RATE = '$replay_sample_rate'
36
+ // @deprecated can be removed along with eager loaded replay
37
+ export const SESSION_RECORDING_MINIMUM_DURATION = '$replay_minimum_duration'
38
+ // @deprecated can be removed along with eager loaded replay
39
+ export const SESSION_RECORDING_SCRIPT_CONFIG = '$replay_script_config'
40
+ export const SESSION_ID = '$sesid'
41
+ export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled'
42
+ export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session'
43
+ export const SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION = '$session_recording_event_trigger_activated_session'
44
+ export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags'
45
+ export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features'
46
+ export const PERSISTENCE_FEATURE_FLAG_DETAILS = '$feature_flag_details'
47
+ export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties'
48
+ export const STORED_GROUP_PROPERTIES_KEY = '$stored_group_properties'
49
+ export const SURVEYS = '$surveys'
50
+ export const SURVEYS_ACTIVATED = '$surveys_activated'
51
+ export const FLAG_CALL_REPORTED = '$flag_call_reported'
52
+ export const USER_STATE = '$user_state'
53
+ export const CLIENT_SESSION_PROPS = '$client_session_props'
54
+ export const CAPTURE_RATE_LIMIT = '$capture_rate_limit'
55
+
56
+ /** @deprecated Delete this when INITIAL_PERSON_INFO has been around for long enough to ignore backwards compat */
57
+ export const INITIAL_CAMPAIGN_PARAMS = '$initial_campaign_params'
58
+ /** @deprecated Delete this when INITIAL_PERSON_INFO has been around for long enough to ignore backwards compat */
59
+ export const INITIAL_REFERRER_INFO = '$initial_referrer_info'
60
+ export const INITIAL_PERSON_INFO = '$initial_person_info'
61
+ export const ENABLE_PERSON_PROCESSING = '$epp'
62
+ export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__'
63
+ export const TOOLBAR_CONTAINER_CLASS = 'toolbar-global-fade-container'
64
+
65
+ /**
66
+ * PREVIEW - MAY CHANGE WITHOUT WARNING - DO NOT USE IN PRODUCTION
67
+ * Sentinel value for distinct id, device id, session id. Signals that the server should generate the value
68
+ * */
69
+ export const COOKIELESS_SENTINEL_VALUE = '$posthog_cookieless'
70
+ export const COOKIELESS_MODE_FLAG_PROPERTY = '$cookieless_mode'
71
+
72
+ export const WEB_EXPERIMENTS = '$web_experiments'
73
+
74
+ // These are properties that are reserved and will not be automatically included in events
75
+ export const PERSISTENCE_RESERVED_PROPERTIES = [
76
+ PEOPLE_DISTINCT_ID_KEY,
77
+ ALIAS_ID_KEY,
78
+ CAMPAIGN_IDS_KEY,
79
+ EVENT_TIMERS_KEY,
80
+ SESSION_RECORDING_ENABLED_SERVER_SIDE,
81
+ HEATMAPS_ENABLED_SERVER_SIDE,
82
+ SESSION_ID,
83
+ ENABLED_FEATURE_FLAGS,
84
+ ERROR_TRACKING_SUPPRESSION_RULES,
85
+ USER_STATE,
86
+ PERSISTENCE_EARLY_ACCESS_FEATURES,
87
+ PERSISTENCE_FEATURE_FLAG_DETAILS,
88
+ STORED_GROUP_PROPERTIES_KEY,
89
+ STORED_PERSON_PROPERTIES_KEY,
90
+ SURVEYS,
91
+ FLAG_CALL_REPORTED,
92
+ CLIENT_SESSION_PROPS,
93
+ CAPTURE_RATE_LIMIT,
94
+ INITIAL_CAMPAIGN_PARAMS,
95
+ INITIAL_REFERRER_INFO,
96
+ ENABLE_PERSON_PROCESSING,
97
+ INITIAL_PERSON_INFO,
98
+ // Ignore posthog persisted properties
99
+ PostHogPersistedProperty.Queue,
100
+ PostHogPersistedProperty.FeatureFlagDetails,
101
+ PostHogPersistedProperty.FlagsEndpointWasHit,
102
+ PostHogPersistedProperty.AnonymousId,
103
+ PostHogPersistedProperty.RemoteConfig,
104
+ PostHogPersistedProperty.Surveys,
105
+ PostHogPersistedProperty.FeatureFlags,
106
+ ]
107
+
108
+ export const SURVEYS_REQUEST_TIMEOUT_MS = 10000
@@ -0,0 +1,34 @@
1
+ // Naive rage click implementation: If mouse has not moved further than RAGE_CLICK_THRESHOLD_PX
2
+ // over RAGE_CLICK_CLICK_COUNT clicks with max RAGE_CLICK_TIMEOUT_MS between clicks, it's
3
+ // counted as a rage click
4
+
5
+ const RAGE_CLICK_THRESHOLD_PX = 30
6
+ const RAGE_CLICK_TIMEOUT_MS = 1000
7
+ const RAGE_CLICK_CLICK_COUNT = 3
8
+
9
+ export default class RageClick {
10
+ clicks: { x: number; y: number; timestamp: number }[]
11
+
12
+ constructor() {
13
+ this.clicks = []
14
+ }
15
+
16
+ isRageClick(x: number, y: number, timestamp: number): boolean {
17
+ const lastClick = this.clicks[this.clicks.length - 1]
18
+ if (
19
+ lastClick &&
20
+ Math.abs(x - lastClick.x) + Math.abs(y - lastClick.y) < RAGE_CLICK_THRESHOLD_PX &&
21
+ timestamp - lastClick.timestamp < RAGE_CLICK_TIMEOUT_MS
22
+ ) {
23
+ this.clicks.push({ x, y, timestamp })
24
+
25
+ if (this.clicks.length === RAGE_CLICK_CLICK_COUNT) {
26
+ return true
27
+ }
28
+ } else {
29
+ this.clicks = [{ x, y, timestamp }]
30
+ }
31
+
32
+ return false
33
+ }
34
+ }
package/src/iife.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { Leanbase } from './leanbase'
2
- import type { LeanbaseOptions } from './leanbase'
3
- import { isFunction, isArray } from '@posthog/core'
4
-
5
- // Lightweight global API with a pre-init queue, inspired by PostHog's snippet pattern
6
- // Exposes window.leanbase with methods: init, capture, identify, group, alias, reset
2
+ import type { LeanbaseConfig } from './types'
3
+ import { isArray } from '@posthog/core'
7
4
 
8
5
  type QueuedCall = { fn: keyof typeof api; args: any[] }
9
6
 
@@ -11,9 +8,8 @@ const api = {
11
8
  _instance: null as Leanbase | null,
12
9
  _queue: [] as QueuedCall[],
13
10
 
14
- init(apiKey: string, options?: LeanbaseOptions) {
11
+ init(apiKey: string, options?: LeanbaseConfig) {
15
12
  this._instance = new Leanbase(apiKey, options)
16
- // Flush queued calls
17
13
  const q = this._queue
18
14
  this._queue = []
19
15
  for (const { fn, args } of q) {
@@ -22,36 +18,44 @@ const api = {
22
18
  }
23
19
  },
24
20
 
25
- capture(...args: any[]) {
21
+ capture(...args: Parameters<NonNullable<typeof this._instance>['capture']>) {
22
+ if (this._instance) {
23
+ return this._instance.capture(...args)
24
+ }
25
+
26
+ this._queue.push({ fn: 'capture', args })
27
+ },
28
+
29
+ captureException(...args: Parameters<NonNullable<typeof this._instance>['captureException']>) {
26
30
  if (this._instance) {
27
- ;(this._instance.capture as any)(...args)
28
- } else {
29
- this._queue.push({ fn: 'capture', args })
31
+ return this._instance.captureException(...args)
30
32
  }
33
+
34
+ this._queue.push({ fn: 'captureException', args })
31
35
  },
32
36
 
33
- identify(...args: any[]) {
37
+ identify(...args: Parameters<NonNullable<typeof this._instance>['identify']>) {
34
38
  if (this._instance) {
35
- ;(this._instance.identify as any)(...args)
36
- } else {
37
- this._queue.push({ fn: 'identify', args })
39
+ return this._instance.identify(...args)
38
40
  }
41
+
42
+ this._queue.push({ fn: 'identify', args })
39
43
  },
40
44
 
41
- group(...args: any[]) {
42
- if (this._instance && isFunction((this._instance as any).group)) {
43
- ;(this._instance as any).group(...args)
44
- } else {
45
- this._queue.push({ fn: 'group' as any, args })
45
+ group(...args: Parameters<NonNullable<typeof this._instance>['group']>) {
46
+ if (this._instance) {
47
+ return this._instance.group(...args)
46
48
  }
49
+
50
+ this._queue.push({ fn: 'group', args })
47
51
  },
48
52
 
49
- alias(...args: any[]) {
50
- if (this._instance && isFunction((this._instance as any).alias)) {
51
- ;(this._instance as any).alias(...args)
52
- } else {
53
- this._queue.push({ fn: 'alias' as any, args })
53
+ alias(...args: Parameters<NonNullable<typeof this._instance>['alias']>) {
54
+ if (this._instance) {
55
+ return this._instance.alias(...args)
54
56
  }
57
+
58
+ this._queue.push({ fn: 'alias', args })
55
59
  },
56
60
 
57
61
  reset() {
@@ -65,7 +69,7 @@ const api = {
65
69
  }
66
70
 
67
71
  // Attach to globalThis for browsers (SSR-safe guards inside Leanbase)
68
- ;(function attachToGlobal(g: any) {
72
+ ;(function attachToGlobal(g: Window & typeof globalThis & { leanbase?: typeof api; Leanbase?: typeof api }) {
69
73
  // Prefer not to overwrite if a stub already exists (e.g., user queued calls before script loaded)
70
74
  const existing = g.leanbase
71
75
  if (existing && typeof existing === 'object') {
@@ -78,6 +82,6 @@ const api = {
78
82
  g.leanbase = api
79
83
  // Also expose PascalCase alias for familiarity
80
84
  g.Leanbase = g.leanbase
81
- })(globalThis as any)
85
+ })(globalThis as Window & typeof globalThis)
82
86
 
83
87
  export default api
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { Leanbase } from './leanbase'
2
- export type { LeanbaseOptions } from './leanbase'
2
+ export type { LeanbaseConfig as LeanbaseOptions } from './types'