@leanbase-giangnd/js 0.1.5 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leanbase-giangnd/js",
3
- "version": "0.1.5",
3
+ "version": "0.2.3",
4
4
  "description": "Leanbase browser SDK - event tracking, autocapture, and session replay",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,103 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
1
2
  import { window as win } from '../../utils'
2
- import { record as rrwebRecord } from '@rrweb/record'
3
+
4
+ // We avoid importing '@rrweb/record' at module load time to prevent IIFE builds
5
+ // from requiring a top-level global. Instead, expose a lazy proxy that will
6
+ // dynamically import the module the first time it's used.
7
+
8
+ let _cachedRRWeb: any | null = null
9
+
10
+ async function _loadRRWebModule(): Promise<any> {
11
+ if (_cachedRRWeb) return _cachedRRWeb
12
+ try {
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ const mod: any = await import('@rrweb/record')
15
+ _cachedRRWeb = mod
16
+ return _cachedRRWeb
17
+ } catch (e) {
18
+ return null
19
+ }
20
+ }
21
+
22
+ // queue for method calls before rrweb loads
23
+ const _queuedCalls: Array<() => void> = []
24
+
25
+ // Create a proxy function that delegates to the real rrweb.record when called
26
+ const rrwebRecordProxy: any = function (...args: any[]) {
27
+ let realStop: (() => void) | undefined
28
+ let calledReal = false
29
+
30
+ // Start loading asynchronously and call the real record when available
31
+ void (async () => {
32
+ const mod = await _loadRRWebModule()
33
+ const real = mod && (mod.record ?? mod.default?.record)
34
+ if (real) {
35
+ try {
36
+ calledReal = true
37
+ realStop = real(...args)
38
+ // flush any queued calls that were waiting for rrweb
39
+ while (_queuedCalls.length) {
40
+ try {
41
+ const fn = _queuedCalls.shift()!
42
+ fn()
43
+ } catch (e) {
44
+ // ignore
45
+ }
46
+ }
47
+ } catch (e) {
48
+ // ignore
49
+ }
50
+ }
51
+ })()
52
+
53
+ // return a stop function that will call the real stop when available
54
+ return () => {
55
+ if (realStop) {
56
+ try {
57
+ realStop()
58
+ } catch (e) {
59
+ // ignore
60
+ }
61
+ } else if (!calledReal) {
62
+ // If rrweb hasn't been initialised yet, queue a stop request that will
63
+ // call the real stop once available.
64
+ _queuedCalls.push(() => {
65
+ try {
66
+ realStop?.()
67
+ } catch (e) {
68
+ // ignore
69
+ }
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ // methods that can be called on the rrweb.record object - queue until real module is available
76
+ rrwebRecordProxy.addCustomEvent = function (tag?: string, payload?: any) {
77
+ const call = () => {
78
+ try {
79
+ const real = _cachedRRWeb && (_cachedRRWeb.record ?? _cachedRRWeb.default?.record)
80
+ real?.addCustomEvent?.(tag, payload)
81
+ } catch (e) {
82
+ // ignore
83
+ }
84
+ }
85
+ if (_cachedRRWeb) call()
86
+ else _queuedCalls.push(call)
87
+ }
88
+
89
+ rrwebRecordProxy.takeFullSnapshot = function () {
90
+ const call = () => {
91
+ try {
92
+ const real = _cachedRRWeb && (_cachedRRWeb.record ?? _cachedRRWeb.default?.record)
93
+ real?.takeFullSnapshot?.()
94
+ } catch (e) {
95
+ // ignore
96
+ }
97
+ }
98
+ if (_cachedRRWeb) call()
99
+ else _queuedCalls.push(call)
100
+ }
3
101
  // Delay importing heavy modules to avoid circular dependencies at build time.
4
102
  // They will be required lazily when used at runtime.
5
103
  // We avoid requiring the lazy-loaded recorder here to prevent circular dependencies during bundling.
@@ -12,9 +110,10 @@ const _target: any = (win as any) ?? (globalThis as any)
12
110
 
13
111
  _target.__PosthogExtensions__ = _target.__PosthogExtensions__ || {}
14
112
 
15
- // Expose rrweb.record under the same contract
113
+ // Expose rrweb.record under the same contract. We provide a lazy proxy so
114
+ // builds that execute this file don't require rrweb at module evaluation time.
16
115
  _target.__PosthogExtensions__.rrweb = _target.__PosthogExtensions__.rrweb || {
17
- record: rrwebRecord,
116
+ record: rrwebRecordProxy,
18
117
  }
19
118
 
20
119
  // Provide initSessionRecording if not present — return a new LazyLoadedSessionRecording when called
@@ -1,5 +1,4 @@
1
1
  /* eslint-disable posthog-js/no-direct-function-check */
2
- import { record as rrwebRecord } from '@rrweb/record'
3
2
  import '../extension-shim'
4
3
  import { clampToRange, includes, isBoolean, isNullish, isNumber, isObject, isString, isUndefined } from '@posthog/core'
5
4
  import type { recordOptions, rrwebRecord as rrwebRecordType } from '../types/rrweb'
@@ -145,7 +144,43 @@ function getRRWebRecord(): rrwebRecordType | undefined {
145
144
  // ignore
146
145
  }
147
146
 
148
- return rrwebRecord as unknown as rrwebRecordType
147
+ // If we've previously loaded rrweb via dynamic import, return the cached reference
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ const cached = (getRRWebRecord as any)._cachedRRWebRecord as rrwebRecordType | undefined
150
+ return cached as unknown as rrwebRecordType | undefined
151
+ }
152
+
153
+ async function loadRRWeb(): Promise<rrwebRecordType | null> {
154
+ try {
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
+ const ext = (globalThis as any).__PosthogExtensions__
157
+ if (ext && ext.rrweb && ext.rrweb.record) {
158
+ ;(getRRWebRecord as any)._cachedRRWebRecord = ext.rrweb.record as unknown as rrwebRecordType
159
+ return ext.rrweb.record as unknown as rrwebRecordType
160
+ }
161
+
162
+ // If already cached, return it
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ const already = (getRRWebRecord as any)._cachedRRWebRecord as rrwebRecordType | undefined
165
+ if (already) {
166
+ return already
167
+ }
168
+
169
+ // Dynamic import - let the bundler (IIFE build) include rrweb in the bundle or allow lazy-load
170
+ // Note: we intentionally use a dynamic import so rrweb is not referenced at the module top-level
171
+ // which would cause IIFE builds to assume a global is present at script execution.
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
+ const mod: any = await import('@rrweb/record')
174
+ const rr = (mod && (mod.record ?? (mod.default && mod.default.record))) as rrwebRecordType
175
+ if (rr) {
176
+ ;(getRRWebRecord as any)._cachedRRWebRecord = rr
177
+ return rr
178
+ }
179
+ } catch (e) {
180
+ logger.error('could not dynamically load rrweb', e)
181
+ }
182
+
183
+ return null
149
184
  }
150
185
 
151
186
  export type compressedFullSnapshotEvent = {
@@ -681,7 +716,7 @@ export class LazyLoadedSessionRecording {
681
716
  return parsedConfig as SessionRecordingPersistedConfig
682
717
  }
683
718
 
684
- start(startReason?: SessionStartReason) {
719
+ async start(startReason?: SessionStartReason) {
685
720
  const config = this._remoteConfig
686
721
  if (!config) {
687
722
  logger.info('remote config must be stored in persistence before recording can start')
@@ -722,7 +757,13 @@ export class LazyLoadedSessionRecording {
722
757
  })
723
758
 
724
759
  this._makeSamplingDecision(this.sessionId)
725
- this._startRecorder()
760
+ await this._startRecorder()
761
+
762
+ // If rrweb failed to load/start, do not proceed further.
763
+ // This prevents installing listeners that assume rrweb is active.
764
+ if (!this.isStarted) {
765
+ return
766
+ }
726
767
 
727
768
  // calling addEventListener multiple times is safe and will not add duplicates
728
769
  addEventListener(window, 'beforeunload', this._onBeforeUnload)
@@ -1302,7 +1343,7 @@ export class LazyLoadedSessionRecording {
1302
1343
  }
1303
1344
  }
1304
1345
 
1305
- private _startRecorder() {
1346
+ private async _startRecorder() {
1306
1347
  if (this._stopRrweb) {
1307
1348
  return
1308
1349
  }
@@ -1353,7 +1394,13 @@ export class LazyLoadedSessionRecording {
1353
1394
  sessionRecordingOptions.blockSelector = this._masking.blockSelector ?? undefined
1354
1395
  }
1355
1396
 
1356
- const rrwebRecord = getRRWebRecord()
1397
+ // Ensure rrweb is loaded (either via global extension or dynamic import)
1398
+ let rrwebRecord = getRRWebRecord()
1399
+ if (!rrwebRecord) {
1400
+ const loaded = await loadRRWeb()
1401
+ rrwebRecord = loaded ?? undefined
1402
+ }
1403
+
1357
1404
  if (!rrwebRecord) {
1358
1405
  logger.error(
1359
1406
  '_startRecorder was called but rrwebRecord is not available. This indicates something has gone wrong.'
@@ -1377,13 +1424,19 @@ export class LazyLoadedSessionRecording {
1377
1424
  })
1378
1425
 
1379
1426
  const activePlugins = this._gatherRRWebPlugins()
1380
- this._stopRrweb = rrwebRecord({
1381
- emit: (event) => {
1382
- this.onRRwebEmit(event)
1383
- },
1384
- plugins: activePlugins,
1385
- ...sessionRecordingOptions,
1386
- })
1427
+ try {
1428
+ this._stopRrweb = rrwebRecord({
1429
+ emit: (event) => {
1430
+ this.onRRwebEmit(event)
1431
+ },
1432
+ plugins: activePlugins,
1433
+ ...sessionRecordingOptions,
1434
+ })
1435
+ } catch (e) {
1436
+ logger.error('failed to start rrweb recorder', e)
1437
+ this._stopRrweb = undefined
1438
+ return
1439
+ }
1387
1440
 
1388
1441
  // We reset the last activity timestamp, resetting the idle timer
1389
1442
  this._lastActivityTimestamp = Date.now()
@@ -1,3 +1,4 @@
1
+ /* eslint-disable posthog-js/no-direct-function-check */
1
2
  import { SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_REMOTE_CONFIG } from '../../constants'
2
3
  import { Leanbase } from '../../leanbase'
3
4
  import { Properties, RemoteConfig, SessionRecordingPersistedConfig, SessionStartReason } from '../../types'
@@ -5,7 +6,7 @@ import { type eventWithTime } from './types/rrweb-types'
5
6
 
6
7
  import { isNullish, isUndefined } from '@posthog/core'
7
8
  import { logger } from '../../leanbase-logger'
8
- import { window } from '../../utils'
9
+ import { assignableWindow, window } from '../../utils'
9
10
  import { LazyLoadedSessionRecording } from './external/lazy-loaded-session-recorder'
10
11
  import { DISABLED, LAZY_LOADING, SessionRecordingStatus, TriggerType } from './external/triggerMatching'
11
12
 
@@ -91,6 +92,19 @@ export class SessionRecording {
91
92
  return
92
93
  }
93
94
 
95
+ // If extensions provide a loader, use it. Otherwise fallback to the local _onScriptLoaded which
96
+ // will create the local LazyLoadedSessionRecording (so tests that mock it work correctly).
97
+ const loader = assignableWindow.__PosthogExtensions__?.loadExternalDependency
98
+ if (typeof loader === 'function') {
99
+ loader(this._instance, this._scriptName as any, (err: any) => {
100
+ if (err) {
101
+ return log.error('could not load recorder', err)
102
+ }
103
+ this._onScriptLoaded(startReason)
104
+ })
105
+ return
106
+ }
107
+
94
108
  this._onScriptLoaded(startReason)
95
109
  }
96
110
 
@@ -188,14 +202,57 @@ export class SessionRecording {
188
202
  }
189
203
  }
190
204
 
205
+ private get _scriptName() {
206
+ const remoteConfig: SessionRecordingPersistedConfig | undefined = this._instance?.persistence?.get_property(
207
+ SESSION_RECORDING_REMOTE_CONFIG
208
+ )
209
+ return (remoteConfig?.scriptConfig?.script as any) || 'lazy-recorder'
210
+ }
211
+
191
212
  private _onScriptLoaded(startReason?: SessionStartReason) {
213
+ // If extensions provide an init function, use it. Otherwise, fall back to the local LazyLoadedSessionRecording
214
+ if (assignableWindow.__PosthogExtensions__?.initSessionRecording) {
215
+ if (!this._lazyLoadedSessionRecording) {
216
+ const maybeRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(this._instance)
217
+ if (maybeRecording && typeof (maybeRecording as any).start === 'function') {
218
+ this._lazyLoadedSessionRecording = maybeRecording
219
+ ;(this._lazyLoadedSessionRecording as any)._forceAllowLocalhostNetworkCapture =
220
+ this._forceAllowLocalhostNetworkCapture
221
+ } else {
222
+ log.warn(
223
+ 'initSessionRecording was present but did not return a recorder instance; falling back to local recorder'
224
+ )
225
+ }
226
+ }
227
+
228
+ if (this._lazyLoadedSessionRecording) {
229
+ try {
230
+ const maybePromise: any = this._lazyLoadedSessionRecording.start(startReason)
231
+ if (maybePromise && typeof maybePromise.catch === 'function') {
232
+ maybePromise.catch((e: any) => logger.error('error starting session recording', e))
233
+ }
234
+ } catch (e: any) {
235
+ logger.error('error starting session recording', e)
236
+ }
237
+ return
238
+ }
239
+ }
240
+
192
241
  if (!this._lazyLoadedSessionRecording) {
193
242
  this._lazyLoadedSessionRecording = new LazyLoadedSessionRecording(this._instance)
194
243
  ;(this._lazyLoadedSessionRecording as any)._forceAllowLocalhostNetworkCapture =
195
244
  this._forceAllowLocalhostNetworkCapture
196
245
  }
197
246
 
198
- this._lazyLoadedSessionRecording.start(startReason)
247
+ // start may perform a dynamic import; handle both sync and Promise returns
248
+ try {
249
+ const maybePromise: any = this._lazyLoadedSessionRecording!.start(startReason)
250
+ if (maybePromise && typeof maybePromise.catch === 'function') {
251
+ maybePromise.catch((e: any) => logger.error('error starting session recording', e))
252
+ }
253
+ } catch (e: any) {
254
+ logger.error('error starting session recording', e)
255
+ }
199
256
  }
200
257
 
201
258
  /**
package/src/leanbase.ts CHANGED
@@ -93,6 +93,10 @@ export class Leanbase extends PostHogCore {
93
93
  consent!: ConsentManager
94
94
  sessionRecording?: SessionRecording
95
95
  isRemoteConfigLoaded?: boolean
96
+ private _remoteConfigLoadAttempted: boolean = false
97
+ private _remoteConfigResolved: boolean = false
98
+ private _featureFlagsResolved: boolean = false
99
+ private _maybeStartedSessionRecording: boolean = false
96
100
  personProcessingSetOncePropertiesSent = false
97
101
  isLoaded: boolean = false
98
102
  initialPageviewCaptured: boolean
@@ -130,11 +134,21 @@ export class Leanbase extends PostHogCore {
130
134
 
131
135
  if (this.sessionManager && this.config.cookieless_mode !== 'always') {
132
136
  this.sessionRecording = new SessionRecording(this)
133
- this.sessionRecording.startIfEnabledOrStop()
134
137
  }
135
138
 
139
+ // Start session recording only once flags + remote config have been resolved.
140
+ // This matches the PostHog browser SDK where replay activation is driven by remote config and flags.
136
141
  if (this.config.preloadFeatureFlags !== false) {
137
- this.reloadFeatureFlags()
142
+ this.reloadFeatureFlags({
143
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
144
+ cb: (_err) => {
145
+ this._featureFlagsResolved = true
146
+ this._maybeStartSessionRecording()
147
+ },
148
+ })
149
+ } else {
150
+ // If feature flags preload is explicitly disabled, treat this requirement as satisfied.
151
+ this._featureFlagsResolved = true
138
152
  }
139
153
 
140
154
  this.config.loaded?.(this)
@@ -146,9 +160,25 @@ export class Leanbase extends PostHogCore {
146
160
  }, 1)
147
161
  }
148
162
 
149
- addEventListener(document, 'DOMContentLoaded', () => {
150
- this.loadRemoteConfig()
151
- })
163
+ const triggerRemoteConfigLoad = (reason: 'immediate' | 'dom' | 'no-document') => {
164
+ logger.info(`remote config load triggered via ${reason}`)
165
+ void this.loadRemoteConfig()
166
+ }
167
+
168
+ if (document) {
169
+ if (document.readyState === 'loading') {
170
+ logger.info('remote config load deferred until DOMContentLoaded')
171
+ const onDomReady = () => {
172
+ document?.removeEventListener('DOMContentLoaded', onDomReady)
173
+ triggerRemoteConfigLoad('dom')
174
+ }
175
+ addEventListener(document, 'DOMContentLoaded', onDomReady, { once: true } as any)
176
+ } else {
177
+ triggerRemoteConfigLoad('immediate')
178
+ }
179
+ } else {
180
+ triggerRemoteConfigLoad('no-document')
181
+ }
152
182
  addEventListener(window, 'onpagehide' in self ? 'pagehide' : 'unload', this.capturePageLeave.bind(this), {
153
183
  passive: false,
154
184
  })
@@ -191,11 +221,20 @@ export class Leanbase extends PostHogCore {
191
221
  }
192
222
 
193
223
  async loadRemoteConfig() {
194
- if (!this.isRemoteConfigLoaded) {
224
+ if (this._remoteConfigLoadAttempted) {
225
+ return
226
+ }
227
+ this._remoteConfigLoadAttempted = true
228
+
229
+ try {
195
230
  const remoteConfig = await this.reloadRemoteConfigAsync()
196
231
  if (remoteConfig) {
197
232
  this.onRemoteConfig(remoteConfig as RemoteConfig)
198
233
  }
234
+ } finally {
235
+ // Regardless of success/failure, we consider remote config "resolved" so replay isn't blocked forever.
236
+ this._remoteConfigResolved = true
237
+ this._maybeStartSessionRecording()
199
238
  }
200
239
  }
201
240
 
@@ -210,6 +249,29 @@ export class Leanbase extends PostHogCore {
210
249
  this.isRemoteConfigLoaded = true
211
250
  this.replayAutocapture?.onRemoteConfig(config)
212
251
  this.sessionRecording?.onRemoteConfig(config)
252
+
253
+ // Remote config has been applied; allow replay start if flags are also ready.
254
+ this._remoteConfigResolved = true
255
+ this._maybeStartSessionRecording()
256
+ }
257
+
258
+ private _maybeStartSessionRecording(): void {
259
+ if (this._maybeStartedSessionRecording) {
260
+ return
261
+ }
262
+ if (!this.sessionRecording) {
263
+ return
264
+ }
265
+ if (!this._featureFlagsResolved || !this._remoteConfigResolved) {
266
+ return
267
+ }
268
+
269
+ this._maybeStartedSessionRecording = true
270
+ try {
271
+ this.sessionRecording.startIfEnabledOrStop()
272
+ } catch (e) {
273
+ logger.error('Failed to start session recording', e)
274
+ }
213
275
  }
214
276
 
215
277
  fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> {
@@ -22,6 +22,9 @@ export const AbortController = global?.AbortController
22
22
  export const userAgent = navigator?.userAgent
23
23
  export { win as window }
24
24
 
25
+ // assignableWindow mirrors browser package's assignableWindow for extension loading shims
26
+ export const assignableWindow: (Window & typeof globalThis) | any = win ?? ({} as any)
27
+
25
28
  export function eachArray<E = any>(
26
29
  obj: E[] | null | undefined,
27
30
  iterator: (value: E, key: number) => void | Breaker,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.1.5'
1
+ export const version = '0.2.3'