@leanbase-giangnd/js 0.3.1 → 0.4.0

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.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Leanbase browser SDK - event tracking, autocapture, and session replay",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,6 +35,7 @@
35
35
  "dependencies": {
36
36
  "@posthog/core": "workspace:*",
37
37
  "@rrweb/record": "2.0.0-alpha.17",
38
+ "@rrweb/rrweb-plugin-console-record": "2.0.0-alpha.17",
38
39
  "fflate": "^0.4.8"
39
40
  },
40
41
  "devDependencies": {
@@ -318,12 +318,18 @@ export class LazyLoadedSessionRecording {
318
318
  * Util to help developers working on this feature manually override
319
319
  */
320
320
  private _forceAllowLocalhostNetworkCapture = false
321
+ private _debug(...args: any[]) {
322
+ if (this._instance?.config?.debug) {
323
+ logger.info(...args)
324
+ }
325
+ }
321
326
  // "Started" is only true once we have a valid rrweb stop handler AND we have marked recording enabled.
322
327
  // If rrweb fails to initialize, we permanently disable replay for this page load.
323
328
  private _recording: { stop: listenerHandler } | undefined
324
329
  private _stopRrweb: listenerHandler | undefined = undefined
325
330
  private _permanentlyDisabled: boolean = false
326
331
  private _loggedPermanentlyDisabled: boolean = false
332
+ private _hasReportedRecordingInitialized: boolean = false
327
333
  private _lastActivityTimestamp: number = Date.now()
328
334
  /**
329
335
  * if pageview capture is disabled,
@@ -536,31 +542,84 @@ export class LazyLoadedSessionRecording {
536
542
  }
537
543
  }
538
544
 
539
- private _gatherRRWebPlugins() {
545
+ private async _loadConsolePlugin(): Promise<RecordPlugin | null> {
546
+ try {
547
+ const mod: any = await import('@rrweb/rrweb-plugin-console-record')
548
+ const factory = mod?.getRecordConsolePlugin ?? mod?.default?.getRecordConsolePlugin
549
+
550
+ if (typeof factory === 'function') {
551
+ const plugin = factory()
552
+ this._debug('Console plugin loaded')
553
+ return plugin
554
+ }
555
+
556
+ logger.warn('console plugin factory unavailable after import')
557
+ } catch (e) {
558
+ logger.warn('could not load console plugin', e)
559
+ }
560
+
561
+ return null
562
+ }
563
+
564
+ private async _loadNetworkPlugin(
565
+ networkPayloadCapture: Pick<
566
+ NetworkRecordOptions,
567
+ 'recordHeaders' | 'recordBody' | 'recordPerformance' | 'payloadHostDenyList'
568
+ >
569
+ ): Promise<RecordPlugin | null> {
570
+ try {
571
+ const mod: any = await import('./network-plugin')
572
+ const factory = mod?.getRecordNetworkPlugin ?? mod?.default?.getRecordNetworkPlugin
573
+
574
+ if (typeof factory === 'function') {
575
+ const options = buildNetworkRequestOptions(this._instance.config, networkPayloadCapture)
576
+ const plugin = factory(options)
577
+ this._debug('Network plugin loaded')
578
+ return plugin
579
+ }
580
+
581
+ logger.warn('network plugin factory unavailable after import')
582
+ } catch (e) {
583
+ logger.warn('could not load network plugin', e)
584
+ }
585
+
586
+ return null
587
+ }
588
+
589
+ private async _gatherRRWebPlugins(): Promise<RecordPlugin[]> {
540
590
  const plugins: RecordPlugin[] = []
541
591
 
592
+ if (!window) {
593
+ return plugins
594
+ }
595
+
542
596
  if (this._isConsoleLogCaptureEnabled) {
543
- logger.info('Console log capture requested but console plugin is not bundled in this build yet.')
597
+ const consolePlugin = await this._loadConsolePlugin()
598
+ if (consolePlugin) {
599
+ plugins.push(consolePlugin)
600
+ }
544
601
  }
545
602
 
546
603
  if (this._networkPayloadCapture) {
547
604
  const canRecordNetwork = !isLocalhost() || this._forceAllowLocalhostNetworkCapture
548
605
 
549
606
  if (canRecordNetwork) {
550
- const assignableWindow: any = globalThis
551
- const networkFactory = assignableWindow.__PosthogExtensions__?.rrwebPlugins?.getRecordNetworkPlugin?.()
552
- if (typeof networkFactory === 'function') {
553
- plugins.push(
554
- networkFactory(buildNetworkRequestOptions(this._instance.config, this._networkPayloadCapture))
555
- )
556
- } else {
557
- logger.info('Network plugin factory not available yet; skipping network plugin')
607
+ const networkPlugin = await this._loadNetworkPlugin(this._networkPayloadCapture)
608
+ if (networkPlugin) {
609
+ plugins.push(networkPlugin)
558
610
  }
559
611
  } else {
560
- logger.info('NetworkCapture not started because we are on localhost.')
612
+ this._debug('NetworkCapture not started because we are on localhost.')
561
613
  }
562
614
  }
563
615
 
616
+ if (plugins.length > 0) {
617
+ this._debug(
618
+ 'Replay plugins loaded',
619
+ plugins.map((p) => p.name)
620
+ )
621
+ }
622
+
564
623
  return plugins
565
624
  }
566
625
 
@@ -886,7 +945,13 @@ export class LazyLoadedSessionRecording {
886
945
  }
887
946
 
888
947
  if (this.status === ACTIVE) {
889
- this._reportStarted(startReason || 'recording_initialized')
948
+ const reason = startReason || 'recording_initialized'
949
+ if (reason !== 'recording_initialized' || !this._hasReportedRecordingInitialized) {
950
+ if (reason === 'recording_initialized') {
951
+ this._hasReportedRecordingInitialized = true
952
+ }
953
+ this._reportStarted(reason)
954
+ }
890
955
  }
891
956
  }
892
957
 
@@ -942,19 +1007,36 @@ export class LazyLoadedSessionRecording {
942
1007
  this._recording = undefined
943
1008
  this._stopRrweb = undefined
944
1009
  this._isFullyReady = false
1010
+ this._hasReportedRecordingInitialized = false
945
1011
 
946
1012
  logger.info('stopped')
947
1013
  }
948
1014
 
949
1015
  private _snapshotIngestionUrl(): string | null {
950
1016
  const endpointFor = (this._instance as any)?.requestRouter?.endpointFor
951
- if (typeof endpointFor !== 'function') {
1017
+
1018
+ // Prefer requestRouter (parity with Browser SDK)
1019
+ if (typeof endpointFor === 'function') {
1020
+ try {
1021
+ return endpointFor('api', this._endpoint)
1022
+ } catch {
1023
+ return null
1024
+ }
1025
+ }
1026
+
1027
+ // Fallback: construct from host/api_host if requestRouter is unavailable (older IIFE builds)
1028
+ const host = (this._instance.config.api_host || this._instance.config.host || '').trim()
1029
+ if (!host) {
952
1030
  return null
953
1031
  }
1032
+
954
1033
  try {
955
- return endpointFor('api', this._endpoint)
1034
+ // eslint-disable-next-line compat/compat
1035
+ return new URL(this._endpoint, host).href
956
1036
  } catch {
957
- return null
1037
+ const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host
1038
+ const normalizedEndpoint = this._endpoint.startsWith('/') ? this._endpoint : `/${this._endpoint}`
1039
+ return `${normalizedHost}${normalizedEndpoint}`
958
1040
  }
959
1041
  }
960
1042
 
@@ -1315,7 +1397,12 @@ export class LazyLoadedSessionRecording {
1315
1397
  this._instance.registerForSession({
1316
1398
  $session_recording_start_reason: startReason,
1317
1399
  })
1318
- logger.info(startReason.replace('_', ' '), tagPayload)
1400
+ const message = startReason.replace('_', ' ')
1401
+ if (typeof tagPayload === 'undefined') {
1402
+ logger.info(message)
1403
+ } else {
1404
+ logger.info(message, tagPayload)
1405
+ }
1319
1406
  if (!includes(['recording_initialized', 'session_id_changed'], startReason)) {
1320
1407
  this._tryAddCustomEvent(startReason, tagPayload)
1321
1408
  }
@@ -1547,7 +1634,7 @@ export class LazyLoadedSessionRecording {
1547
1634
  return
1548
1635
  }
1549
1636
 
1550
- const activePlugins = this._gatherRRWebPlugins()
1637
+ const activePlugins = await this._gatherRRWebPlugins()
1551
1638
 
1552
1639
  let stopHandler: listenerHandler | undefined
1553
1640
  try {
@@ -25,6 +25,12 @@ export class SessionRecording {
25
25
  private _persistFlagsOnSessionListener: (() => void) | undefined = undefined
26
26
  private _lazyLoadedSessionRecording: LazyLoadedSessionRecording | undefined
27
27
 
28
+ private _debug(...args: any[]) {
29
+ if (this._instance?.config?.debug) {
30
+ log.info(...args)
31
+ }
32
+ }
33
+
28
34
  public get started(): boolean {
29
35
  return !!this._lazyLoadedSessionRecording?.isStarted
30
36
  }
@@ -66,8 +72,10 @@ export class SessionRecording {
66
72
 
67
73
  const canRunReplay = !isUndefined(Object.assign) && !isUndefined(Array.from)
68
74
  if (this._isRecordingEnabled && canRunReplay) {
75
+ this._debug('Session replay enabled; starting recorder')
69
76
  this._lazyLoadAndStart(startReason)
70
77
  } else {
78
+ this._debug('Session replay disabled; stopping recorder')
71
79
  this.stopRecording()
72
80
  }
73
81
  }
@@ -156,13 +164,8 @@ export class SessionRecording {
156
164
  log.info('skipping remote config with no sessionRecording', response)
157
165
  return
158
166
  }
159
- this._receivedFlags = true
160
-
161
- if (response.sessionRecording === false) {
162
- return
163
- }
164
-
165
167
  this._persistRemoteConfig(response)
168
+ this._receivedFlags = true
166
169
  this.startIfEnabledOrStop()
167
170
  }
168
171
 
package/src/leanbase.ts CHANGED
@@ -43,7 +43,7 @@ import { PageViewManager } from './page-view'
43
43
  import { ScrollManager } from './scroll-manager'
44
44
  import { isLikelyBot } from './utils/blocked-uas'
45
45
  import { SessionRecording } from './extensions/replay/session-recording'
46
- import type { RequestRouter } from '../../browser/lib/src/utils/request-router'
46
+ import { RequestRouter } from './utils/request-router'
47
47
  import type { ConsentManager } from '../../browser/lib/src/consent'
48
48
 
49
49
  const defaultConfig = (): LeanbaseConfig => ({
@@ -124,6 +124,9 @@ export class Leanbase extends PostHogCore {
124
124
  this.isLoaded = true
125
125
  this.persistence = new LeanbasePersistence(this.config)
126
126
 
127
+ // Browser SDK always has a requestRouter; session replay relies on it for $snapshot ingestion URLs.
128
+ this.requestRouter = new RequestRouter(this)
129
+
127
130
  if (this.config.cookieless_mode !== 'always') {
128
131
  this.sessionManager = new SessionIdManager(this)
129
132
  this.sessionPropsManager = new SessionPropsManager(this, this.sessionManager, this.persistence)
@@ -0,0 +1,39 @@
1
+ import type { Leanbase } from '../leanbase'
2
+
3
+ export type RequestRouterTarget = 'api' | 'ui' | 'assets'
4
+
5
+ /**
6
+ * Leanbase-local version of PostHog's RequestRouter.
7
+ *
8
+ * Browser SDK always has a requestRouter instance; Leanbase IIFE needs this too so
9
+ * features like Session Replay can construct ingestion URLs.
10
+ */
11
+ export class RequestRouter {
12
+ // eslint-disable-next-line @typescript-eslint/naming-convention
13
+ constructor(private readonly instance: Leanbase) {}
14
+
15
+ get apiHost(): string {
16
+ const configured = (this.instance.config.api_host || this.instance.config.host || '').trim()
17
+ return configured.replace(/\/$/, '')
18
+ }
19
+
20
+ get uiHost(): string | undefined {
21
+ const configured = this.instance.config.ui_host?.trim().replace(/\/$/, '')
22
+ return configured || undefined
23
+ }
24
+
25
+ endpointFor(target: RequestRouterTarget, path: string = ''): string {
26
+ if (path) {
27
+ path = path[0] === '/' ? path : `/${path}`
28
+ }
29
+
30
+ if (target === 'ui') {
31
+ const host = this.uiHost || this.apiHost
32
+ return host + path
33
+ }
34
+
35
+ // Leanbase doesn't currently do region-based routing; default to apiHost.
36
+ // Browser's router has special handling for assets; we keep parity in interface, not domains.
37
+ return this.apiHost + path
38
+ }
39
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.3.1'
1
+ export const version = '0.4.0'