@leanbase-giangnd/js 0.0.4 → 0.0.7

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.
@@ -1,29 +1,28 @@
1
1
  import { SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_REMOTE_CONFIG } from '../../constants'
2
- import { Leanbase } from '../../leanbase'
2
+ import { PostHog } from '../../posthog-core'
3
3
  import { Properties, RemoteConfig, SessionRecordingPersistedConfig, SessionStartReason } from '../../types'
4
4
  import { type eventWithTime } from './types/rrweb-types'
5
5
 
6
6
  import { isNullish, isUndefined } from '@posthog/core'
7
- import { logger } from '../../leanbase-logger'
8
- import { window } from '../../utils'
9
- import { LazyLoadedSessionRecording } from './external/lazy-loaded-session-recorder'
7
+ import { createLogger } from '../../utils/logger'
8
+ import {
9
+ assignableWindow,
10
+ LazyLoadedSessionRecordingInterface,
11
+ PostHogExtensionKind,
12
+ window,
13
+ } from '../../utils/globals'
10
14
  import { DISABLED, LAZY_LOADING, SessionRecordingStatus, TriggerType } from './external/triggerMatching'
11
15
 
12
16
  const LOGGER_PREFIX = '[SessionRecording]'
13
- const log = {
14
- info: (...args: any[]) => logger.info(LOGGER_PREFIX, ...args),
15
- warn: (...args: any[]) => logger.warn(LOGGER_PREFIX, ...args),
16
- error: (...args: any[]) => logger.error(LOGGER_PREFIX, ...args),
17
- }
17
+ const logger = createLogger(LOGGER_PREFIX)
18
18
 
19
19
  export class SessionRecording {
20
20
  _forceAllowLocalhostNetworkCapture: boolean = false
21
21
 
22
22
  private _receivedFlags: boolean = false
23
- private _serverRecordingEnabled: boolean = false
24
23
 
25
24
  private _persistFlagsOnSessionListener: (() => void) | undefined = undefined
26
- private _lazyLoadedSessionRecording: LazyLoadedSessionRecording | undefined
25
+ private _lazyLoadedSessionRecording: LazyLoadedSessionRecordingInterface | undefined
27
26
 
28
27
  public get started(): boolean {
29
28
  return !!this._lazyLoadedSessionRecording?.isStarted
@@ -45,9 +44,9 @@ export class SessionRecording {
45
44
  return LAZY_LOADING
46
45
  }
47
46
 
48
- constructor(private readonly _instance: Leanbase) {
47
+ constructor(private readonly _instance: PostHog) {
49
48
  if (!this._instance.sessionManager) {
50
- log.error('started without valid sessionManager')
49
+ logger.error('started without valid sessionManager')
51
50
  throw new Error(LOGGER_PREFIX + ' started without valid sessionManager. This is a bug.')
52
51
  }
53
52
 
@@ -57,30 +56,14 @@ export class SessionRecording {
57
56
  }
58
57
 
59
58
  private get _isRecordingEnabled() {
60
- if (!window) {
61
- return false
62
- }
63
-
64
- if (!this._serverRecordingEnabled) {
65
- return false
66
- }
67
-
68
- if (this._instance.config.disable_session_recording) {
69
- return false
70
- }
71
-
72
- return true
59
+ const enabled_server_side = !!this._instance.get_property(SESSION_RECORDING_REMOTE_CONFIG)?.enabled
60
+ const enabled_client_side = !this._instance.config.disable_session_recording
61
+ const isDisabled = this._instance.config.disable_session_recording || !!this._instance.consent?.isOptedOut?.()
62
+ return window && enabled_server_side && enabled_client_side && !isDisabled
73
63
  }
74
64
 
75
65
  startIfEnabledOrStop(startReason?: SessionStartReason) {
76
- const canRunReplay = !isUndefined(Object.assign) && !isUndefined(Array.from)
77
-
78
- if (!this._isRecordingEnabled || !canRunReplay) {
79
- this.stopRecording()
80
- return
81
- }
82
-
83
- if (this._lazyLoadedSessionRecording?.isStarted) {
66
+ if (this._isRecordingEnabled && this._lazyLoadedSessionRecording?.isStarted) {
84
67
  return
85
68
  }
86
69
 
@@ -91,8 +74,13 @@ export class SessionRecording {
91
74
  // However, MutationObserver does exist on IE11, it just doesn't work well and does not detect all changes.
92
75
  // Instead, when we load "recorder.js", the first JS error is about "Object.assign" and "Array.from" being undefined.
93
76
  // Thus instead of MutationObserver, we look for this function and block recording if it's undefined.
94
- this._lazyLoadAndStart(startReason)
95
- log.info('starting')
77
+ const canRunReplay = !isUndefined(Object.assign) && !isUndefined(Array.from)
78
+ if (this._isRecordingEnabled && canRunReplay) {
79
+ this._lazyLoadAndStart(startReason)
80
+ logger.info('starting')
81
+ } else {
82
+ this.stopRecording()
83
+ }
96
84
  }
97
85
 
98
86
  /**
@@ -109,7 +97,25 @@ export class SessionRecording {
109
97
  return
110
98
  }
111
99
 
112
- this._onScriptLoaded(startReason)
100
+ // If recorder.js is already loaded (if array.full.js snippet is used or posthog-js/dist/recorder is
101
+ // imported), don't load the script. Otherwise, remotely import recorder.js from cdn since it hasn't been loaded.
102
+ if (
103
+ !assignableWindow?.__PosthogExtensions__?.rrweb?.record ||
104
+ !assignableWindow.__PosthogExtensions__?.initSessionRecording
105
+ ) {
106
+ assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(
107
+ this._instance,
108
+ this._scriptName,
109
+ (err) => {
110
+ if (err) {
111
+ return logger.error('could not load recorder', err)
112
+ }
113
+ this._onScriptLoaded(startReason)
114
+ }
115
+ )
116
+ } else {
117
+ this._onScriptLoaded(startReason)
118
+ }
113
119
  }
114
120
 
115
121
  stopRecording() {
@@ -173,28 +179,20 @@ export class SessionRecording {
173
179
  }
174
180
  }
175
181
 
176
- private _clearRemoteConfig() {
177
- this._instance.persistence?.unregister(SESSION_RECORDING_REMOTE_CONFIG)
178
- this._resetSampling()
179
- }
180
-
181
182
  onRemoteConfig(response: RemoteConfig) {
182
183
  if (!('sessionRecording' in response)) {
183
184
  // if sessionRecording is not in the response, we do nothing
184
- log.info('skipping remote config with no sessionRecording', response)
185
+ logger.info('skipping remote config with no sessionRecording', response)
185
186
  return
186
187
  }
187
- this._receivedFlags = true
188
-
189
188
  if (response.sessionRecording === false) {
190
- this._serverRecordingEnabled = false
191
- this._clearRemoteConfig()
192
- this.stopRecording()
189
+ // remotely disabled
190
+ this._receivedFlags = true
193
191
  return
194
192
  }
195
193
 
196
- this._serverRecordingEnabled = true
197
194
  this._persistRemoteConfig(response)
195
+ this._receivedFlags = true
198
196
  this.startIfEnabledOrStop()
199
197
  }
200
198
 
@@ -206,9 +204,22 @@ export class SessionRecording {
206
204
  }
207
205
  }
208
206
 
207
+ private get _scriptName(): PostHogExtensionKind {
208
+ const remoteConfig: SessionRecordingPersistedConfig | undefined = this._instance?.persistence?.get_property(
209
+ SESSION_RECORDING_REMOTE_CONFIG
210
+ )
211
+ return (remoteConfig?.scriptConfig?.script as PostHogExtensionKind) || 'lazy-recorder'
212
+ }
213
+
209
214
  private _onScriptLoaded(startReason?: SessionStartReason) {
215
+ if (!assignableWindow.__PosthogExtensions__?.initSessionRecording) {
216
+ throw Error('Called on script loaded before session recording is available')
217
+ }
218
+
210
219
  if (!this._lazyLoadedSessionRecording) {
211
- this._lazyLoadedSessionRecording = new LazyLoadedSessionRecording(this._instance)
220
+ this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(
221
+ this._instance
222
+ )
212
223
  ;(this._lazyLoadedSessionRecording as any)._forceAllowLocalhostNetworkCapture =
213
224
  this._forceAllowLocalhostNetworkCapture
214
225
  }
@@ -0,0 +1,27 @@
1
+ import { PostHog } from '../../posthog-core'
2
+ import { createLogger } from '../../utils/logger'
3
+ import { isUndefined, isNull } from '@posthog/core'
4
+
5
+ const logger = createLogger('[Stylesheet Loader]')
6
+
7
+ export const prepareStylesheet = (document: Document, innerText: string, posthog?: PostHog) => {
8
+ // Forcing the existence of `document` requires this function to be called in a browser environment
9
+ let stylesheet: HTMLStyleElement | null = document.createElement('style')
10
+ stylesheet.innerText = innerText
11
+
12
+ if (posthog?.config?.prepare_external_dependency_stylesheet) {
13
+ const result = posthog.config.prepare_external_dependency_stylesheet(stylesheet)
14
+ if (!isUndefined(result) && !isNull(result)) {
15
+ stylesheet = result
16
+ } else {
17
+ stylesheet = null
18
+ }
19
+ }
20
+
21
+ if (!stylesheet) {
22
+ logger.error('prepare_external_dependency_stylesheet returned null')
23
+ return null
24
+ }
25
+
26
+ return stylesheet
27
+ }
package/src/leanbase.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  PostHogCore,
3
3
  getFetch,
4
- isArray,
5
4
  isEmptyObject,
6
5
  isEmptyString,
7
6
  isNumber,
8
7
  isObject,
9
8
  isString,
10
9
  isUndefined,
10
+ isFunction,
11
11
  } from '@posthog/core'
12
12
  import type {
13
13
  PostHogEventProperties,
@@ -32,31 +32,25 @@ import {
32
32
  navigator,
33
33
  userAgent,
34
34
  } from './utils'
35
- import { decompressSync, strFromU8 } from 'fflate'
36
35
  import Config from './config'
37
36
  import { Autocapture } from './autocapture'
38
37
  import { logger } from './leanbase-logger'
39
- import { COOKIELESS_MODE_FLAG_PROPERTY, USER_STATE, SESSION_RECORDING_REMOTE_CONFIG } from './constants'
38
+ import { COOKIELESS_MODE_FLAG_PROPERTY, USER_STATE } from './constants'
40
39
  import { getEventProperties } from './utils/event-utils'
41
40
  import { SessionIdManager } from './sessionid'
42
41
  import { SessionPropsManager } from './session-props'
42
+ import { RequestRouter } from './utils/request-router'
43
43
  import { uuidv7 } from './uuidv7'
44
44
  import { PageViewManager } from './page-view'
45
45
  import { ScrollManager } from './scroll-manager'
46
46
  import { isLikelyBot } from './utils/blocked-uas'
47
- import { SessionRecording } from './extensions/replay/session-recording'
48
47
 
49
48
  const defaultConfig = (): LeanbaseConfig => ({
50
49
  host: 'https://i.leanbase.co',
50
+ api_host: 'https://i.leanbase.co',
51
51
  token: '',
52
52
  autocapture: true,
53
53
  rageclick: true,
54
- disable_session_recording: false,
55
- session_recording: {
56
- // Force-enable session recording locally unless explicitly disabled via config
57
- forceClientRecording: true,
58
- },
59
- enable_recording_console_log: undefined,
60
54
  persistence: 'localStorage+cookie',
61
55
  capture_pageview: 'history_change',
62
56
  capture_pageleave: 'if_capture_pageview',
@@ -77,6 +71,7 @@ const defaultConfig = (): LeanbaseConfig => ({
77
71
  opt_out_useragent_filter: false,
78
72
  properties_string_max_length: 65535,
79
73
  loaded: () => {},
74
+ session_recording: {} as any,
80
75
  })
81
76
 
82
77
  export class Leanbase extends PostHogCore {
@@ -89,7 +84,8 @@ export class Leanbase extends PostHogCore {
89
84
  sessionPersistence?: LeanbasePersistence
90
85
  sessionManager?: SessionIdManager
91
86
  sessionPropsManager?: SessionPropsManager
92
- sessionRecording?: SessionRecording
87
+ sessionRecording?: any
88
+ requestRouter: RequestRouter
93
89
  isRemoteConfigLoaded?: boolean
94
90
  personProcessingSetOncePropertiesSent = false
95
91
  isLoaded: boolean = false
@@ -106,6 +102,7 @@ export class Leanbase extends PostHogCore {
106
102
  this.initialPageviewCaptured = false
107
103
  this.scrollManager = new ScrollManager(this)
108
104
  this.pageViewManager = new PageViewManager(this)
105
+ this.requestRouter = new RequestRouter(this)
109
106
  this.init(token, mergedConfig)
110
107
  }
111
108
 
@@ -117,16 +114,19 @@ export class Leanbase extends PostHogCore {
117
114
  )
118
115
  this.isLoaded = true
119
116
  this.persistence = new LeanbasePersistence(this.config)
120
-
121
- if (this.config.cookieless_mode !== 'always') {
122
- this.sessionManager = new SessionIdManager(this)
123
- this.sessionPropsManager = new SessionPropsManager(this, this.sessionManager, this.persistence)
124
- }
125
-
126
117
  this.replayAutocapture = new Autocapture(this)
127
118
  this.replayAutocapture.startIfEnabled()
128
119
 
129
- if (this.sessionManager && this.config.cookieless_mode !== 'always') {
120
+ // Initialize session manager and props before session recording (matches browser behavior)
121
+ if (this.config.cookieless_mode !== 'always') {
122
+ if (!this.sessionManager) {
123
+ this.sessionManager = new SessionIdManager(this)
124
+ this.sessionPropsManager = new SessionPropsManager(this, this.sessionManager, this.persistence as any)
125
+ }
126
+
127
+ // runtime require to lazy-load replay code; allowed for browser parity
128
+ // @ts-expect-error - runtime import only available in browser build
129
+ const { SessionRecording } = require('./extensions/replay/session-recording') // eslint-disable-line @typescript-eslint/no-require-imports
130
130
  this.sessionRecording = new SessionRecording(this)
131
131
  this.sessionRecording.startIfEnabledOrStop()
132
132
  }
@@ -207,135 +207,12 @@ export class Leanbase extends PostHogCore {
207
207
 
208
208
  this.isRemoteConfigLoaded = true
209
209
  this.replayAutocapture?.onRemoteConfig(config)
210
- this.sessionRecording?.onRemoteConfig(config)
211
210
  }
212
211
 
213
- async fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> {
212
+ fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> {
214
213
  const fetchFn = getFetch()
215
214
  if (!fetchFn) {
216
- throw new Error('Fetch API is not available in this environment.')
217
- }
218
-
219
- try {
220
- const isPost = !options.method || options.method.toUpperCase() === 'POST'
221
- const isBatchEndpoint = typeof url === 'string' && url.endsWith('/batch/')
222
-
223
- if (isPost && isBatchEndpoint && options && options.body) {
224
- let parsed: any = null
225
- try {
226
- const headers = (options.headers || {}) as Record<string, any>
227
- const contentEncoding = (
228
- headers['Content-Encoding'] ||
229
- headers['content-encoding'] ||
230
- ''
231
- ).toLowerCase()
232
-
233
- const toUint8 = async (body: any): Promise<Uint8Array | null> => {
234
- if (typeof body === 'string') return new TextEncoder().encode(body)
235
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
236
- const ab = await body.arrayBuffer()
237
- return new Uint8Array(ab)
238
- }
239
- if (body instanceof ArrayBuffer) return new Uint8Array(body)
240
- if (ArrayBuffer.isView(body)) return new Uint8Array((body as any).buffer ?? body)
241
- try {
242
- return new TextEncoder().encode(String(body))
243
- } catch {
244
- return null
245
- }
246
- }
247
-
248
- if (contentEncoding === 'gzip' || contentEncoding === 'deflate') {
249
- const u8 = await toUint8(options.body)
250
- if (u8) {
251
- try {
252
- const dec = decompressSync(u8)
253
- const s = strFromU8(dec)
254
- parsed = JSON.parse(s)
255
- } catch {
256
- parsed = null
257
- }
258
- }
259
- } else {
260
- if (typeof options.body === 'string') {
261
- parsed = JSON.parse(options.body)
262
- } else {
263
- const u8 = await toUint8(options.body)
264
- if (u8) {
265
- try {
266
- parsed = JSON.parse(new TextDecoder().decode(u8))
267
- } catch {
268
- parsed = null
269
- }
270
- } else {
271
- try {
272
- parsed = JSON.parse(String(options.body))
273
- } catch {
274
- parsed = null
275
- }
276
- }
277
- }
278
- }
279
- } catch {
280
- parsed = null
281
- }
282
-
283
- if (parsed && isArray(parsed.batch)) {
284
- const hasSnapshot = parsed.batch.some((item: any) => item && item.event === '$snapshot')
285
- // Debug logging to help diagnose routing issues
286
- try {
287
- // eslint-disable-next-line no-console
288
- console.debug(
289
- '[Leanbase.fetch] parsed.batch.length=',
290
- parsed.batch.length,
291
- 'hasSnapshot=',
292
- hasSnapshot
293
- )
294
- } catch {}
295
-
296
- // If remote config has explicitly disabled session recording, drop snapshot events
297
- try {
298
- // Read persisted remote config that SessionRecording stores
299
- const persisted: any = this.get_property(SESSION_RECORDING_REMOTE_CONFIG)
300
- const serverAllowsRecording = !(
301
- (persisted && persisted.enabled === false) ||
302
- this.config.disable_session_recording === true
303
- )
304
-
305
- if (!serverAllowsRecording && hasSnapshot) {
306
- // remove snapshot events from the batch before sending to /batch/
307
- parsed.batch = parsed.batch.filter((item: any) => !(item && item.event === '$snapshot'))
308
- // If no events remain, short-circuit and avoid sending an empty batch
309
- if (!parsed.batch.length) {
310
- try {
311
- // eslint-disable-next-line no-console
312
- console.debug(
313
- '[Leanbase.fetch] sessionRecording disabled, dropping snapshot-only batch'
314
- )
315
- } catch {}
316
- return { status: 200, json: async () => ({}) } as any
317
- }
318
- // re-encode the body so the underlying fetch receives the modified batch
319
- try {
320
- const newBody = JSON.stringify(parsed)
321
- options = { ...options, body: newBody }
322
- } catch {}
323
- }
324
- } catch {}
325
-
326
- if (hasSnapshot) {
327
- const host = (this.config && this.config.host) || ''
328
- const newUrl = host ? `${host.replace(/\/$/, '')}/s/` : url
329
- try {
330
- // eslint-disable-next-line no-console
331
- console.debug('[Leanbase.fetch] routing snapshot batch to', newUrl)
332
- } catch {}
333
- return fetchFn(newUrl, options)
334
- }
335
- }
336
- }
337
- } catch {
338
- return fetchFn(url, options)
215
+ return Promise.reject(new Error('Fetch API is not available in this environment.'))
339
216
  }
340
217
 
341
218
  return fetchFn(url, options)
@@ -347,7 +224,6 @@ export class Leanbase extends PostHogCore {
347
224
  extend(this.config, config)
348
225
  this.persistence?.update_config(this.config, oldConfig)
349
226
  this.replayAutocapture?.startIfEnabled()
350
- this.sessionRecording?.startIfEnabledOrStop()
351
227
  }
352
228
 
353
229
  const isTempStorage = this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory'
@@ -372,14 +248,43 @@ export class Leanbase extends PostHogCore {
372
248
  return this.persistence?.get_property(key)
373
249
  }
374
250
 
375
- get_property<T = any>(key: string): T | undefined {
251
+ setPersistedProperty<T>(key: PostHogPersistedProperty, value: T | null): void {
252
+ this.persistence?.set_property(key, value)
253
+ }
254
+
255
+ // Backwards-compatible aliases expected by replay/browser code
256
+ get_property(key: string): any {
376
257
  return this.persistence?.get_property(key)
377
258
  }
378
259
 
379
- setPersistedProperty<T>(key: PostHogPersistedProperty, value: T | null): void {
260
+ set_property(key: string, value: any): void {
380
261
  this.persistence?.set_property(key, value)
381
262
  }
382
263
 
264
+ register_for_session(properties: Record<string, any>): void {
265
+ // PostHogCore may expose registerForSession; call it if available
266
+ if (isFunction((this as any).registerForSession)) {
267
+ ;(this as any).registerForSession(properties)
268
+ return
269
+ }
270
+
271
+ // fallback: store properties in sessionPersistence
272
+ if (this.sessionPersistence) {
273
+ Object.keys(properties).forEach((k) => this.sessionPersistence?.set_property(k, properties[k]))
274
+ }
275
+ }
276
+
277
+ unregister_for_session(property: string): void {
278
+ if (isFunction((this as any).unregisterForSession)) {
279
+ ;(this as any).unregisterForSession(property)
280
+ return
281
+ }
282
+
283
+ if (this.sessionPersistence) {
284
+ this.sessionPersistence.set_property(property, null)
285
+ }
286
+ }
287
+
383
288
  calculateEventProperties(
384
289
  eventName: string,
385
290
  eventProperties: PostHogEventProperties,
@@ -429,14 +334,6 @@ export class Leanbase extends PostHogCore {
429
334
  extend(properties, this.sessionPropsManager.getSessionProps())
430
335
  }
431
336
 
432
- try {
433
- if (this.sessionRecording) {
434
- extend(properties, this.sessionRecording.sdkDebugProperties)
435
- }
436
- } catch (e: any) {
437
- properties['$sdk_debug_error_capturing_properties'] = String(e)
438
- }
439
-
440
337
  let pageviewProperties: Record<string, any> = this.pageViewManager.doEvent()
441
338
  if (eventName === '$pageview' && !readOnly) {
442
339
  pageviewProperties = this.pageViewManager.doPageView(timestamp, uuid)
@@ -0,0 +1,12 @@
1
+ import type { Leanbase } from './leanbase'
2
+
3
+ // PostHog shim: Leanbase runtime with a widened onFeatureFlags signature
4
+ export type PostHog = Leanbase & {
5
+ onFeatureFlags?: (cb: (...args: any[]) => void) => () => void
6
+ consent?: {
7
+ isOptedOut?: () => boolean
8
+ }
9
+ }
10
+
11
+ export { default as Config } from './config'
12
+ export { logger } from './utils/logger'