frappe-ui 1.0.0-beta.18 → 1.0.0-beta.19

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,9 +1,17 @@
1
1
  import { reactive, readonly, ref, type App } from 'vue'
2
+ import type { Router, RouteLocationNormalized } from 'vue-router'
2
3
 
3
- import { pulse_provider, PulseProvider } from './pulse.ts'
4
- import { silentCall } from './utils.ts'
4
+ import {
5
+ fetchBootConfig,
6
+ loadPulseClient,
7
+ type BootConfig,
8
+ type PulseAnonymousMode,
9
+ type PulseClient,
10
+ type PulseContext,
11
+ } from './pulse.ts'
5
12
 
6
- let pulseProvider: PulseProvider | null = null
13
+ let client: PulseClient | null = null
14
+ let removePageviewHook: (() => void) | null = null
7
15
 
8
16
  const appName = ref<string>()
9
17
  const isEnabled = ref(false)
@@ -13,18 +21,39 @@ export function useTelemetry() {
13
21
  isEnabled: readonly(isEnabled),
14
22
  disable: () => {
15
23
  isEnabled.value = false
16
- pulseProvider?.setEnabled(false)
17
- pulseProvider?.stop()
24
+ client?.stop()
18
25
  },
19
26
  capture: (event_name: string, data: Record<string, any> = {}) => {
20
27
  if (!isEnabled.value || !appName.value) return
21
- pulseProvider?.capture(event_name, appName.value, data)
28
+ client?.capture(event_name, appName.value, data)
22
29
  },
30
+ getDistinctId: () => client?.getDistinctId?.() ?? '',
23
31
  })
24
32
  }
25
33
 
34
+ export interface TelemetryPluginOptions {
35
+ app_name: string
36
+ host?: string
37
+ apiKey?: string
38
+ site?: string
39
+ enabled?: boolean
40
+ getContext?: () => PulseContext
41
+ anonymousMode?: PulseAnonymousMode
42
+ clientUrl?: string
43
+ // Pass your vue-router to auto-capture a "pageview" per navigation (new sites only,
44
+ // site_age <= 15 days, like desk).
45
+ router?: Router
46
+ // Route → PII-safe string. Defaults to the matched path pattern (e.g. "/orders/:id").
47
+ scrubRoute?: (to: RouteLocationNormalized) => string
48
+ }
49
+
50
+ function defaultScrubRoute(to: RouteLocationNormalized): string {
51
+ const matched = to.matched[to.matched.length - 1]
52
+ return matched?.path || to.path || ''
53
+ }
54
+
26
55
  export default {
27
- async install(app: App, options: { app_name: string }) {
56
+ async install(app: App, options: TelemetryPluginOptions) {
28
57
  appName.value = options.app_name
29
58
 
30
59
  if (!appName.value) {
@@ -36,13 +65,58 @@ export default {
36
65
  return
37
66
  }
38
67
 
39
- isEnabled.value = await silentCall<boolean>(
40
- 'frappe.utils.telemetry.pulse.client.is_enabled',
41
- )
42
- if (!isEnabled.value) return
68
+ // Explicit options win; fetch the rest from the backend (skip when self-sufficient).
69
+ const fetched: BootConfig =
70
+ options.host != null && options.apiKey != null ? {} : await fetchBootConfig()
71
+
72
+ const getContext =
73
+ options.getContext || (() => ({ user: fetched.user, team: fetched.team }))
74
+
75
+ // Reinstalls (tests, HMR, SSR-per-request) reuse the module singletons, so tear
76
+ // down the previous client's flush timer and router hook before replacing them.
77
+ client?.stop()
78
+ removePageviewHook?.()
79
+ removePageviewHook = null
80
+
81
+ client = await loadPulseClient({
82
+ host: options.host ?? fetched.host,
83
+ apiKey: options.apiKey ?? fetched.key,
84
+ site: options.site ?? fetched.site,
85
+ enabled: options.enabled ?? fetched.enabled ?? false,
86
+ getContext,
87
+ anonymousMode: options.anonymousMode,
88
+ clientUrl: options.clientUrl ?? fetched.client_url,
89
+ })
90
+ if (!client) return
43
91
 
44
- pulseProvider = pulse_provider
45
- pulseProvider.setEnabled(true)
46
- pulseProvider.init()
92
+ isEnabled.value = await client.init()
93
+ if (isEnabled.value) {
94
+ removePageviewHook = setupPageviewTracking(options, fetched.site_age)
95
+ }
47
96
  },
48
97
  }
98
+
99
+ // Older backends omit site_age → track anyway (permissive, matching desk's
100
+ // `site_age && site_age > 15` skip check).
101
+ function setupPageviewTracking(
102
+ options: TelemetryPluginOptions,
103
+ site_age: number | undefined,
104
+ ): (() => void) | null {
105
+ if (!options.router || (site_age && site_age > 15)) return null
106
+
107
+ const scrub = options.scrubRoute || defaultScrubRoute
108
+ let lastFullPath = ''
109
+ const capturePageview = (to: RouteLocationNormalized) => {
110
+ // Dedupe so the initial capture and afterEach can't double-count the same route.
111
+ if (!isEnabled.value || !appName.value || to.fullPath === lastFullPath) return
112
+ lastFullPath = to.fullPath
113
+ client?.capture('pageview', appName.value, { route: scrub(to) })
114
+ }
115
+
116
+ // afterEach missed the initial navigation if it resolved during install's awaits,
117
+ // so capture the landing route once the router is ready.
118
+ options.router.isReady().then(() => {
119
+ if (options.router) capturePageview(options.router.currentRoute.value)
120
+ })
121
+ return options.router.afterEach((to) => capturePageview(to))
122
+ }
@@ -1,181 +1,95 @@
1
+ // Loads the one canonical pulse client from pulse's own CDN (not the host backend),
2
+ // so it's independent of the backend's framework version. Degrades to null if the
3
+ // asset can't be loaded, leaving telemetry off.
4
+
1
5
  import { call } from 'frappe-ui'
2
6
 
3
- type PulseEvent = {
4
- captured_at: string
5
- event_name: string
6
- app: string
7
+ export interface PulseContext {
7
8
  user?: string
8
- properties?: Record<string, any>
9
+ team?: string
9
10
  }
10
11
 
11
- export class PulseProvider {
12
- private enabled: boolean
13
- private eq: QueueManager | null
14
- private beforeUnloadAttached: boolean
15
-
16
- constructor() {
17
- this.enabled = false
18
- this.eq = null
19
- this.beforeUnloadAttached = false
20
- }
21
-
22
- setEnabled(isEnabled: boolean) {
23
- this.enabled = isEnabled
24
- if (!this.enabled) {
25
- this.eq?.stop()
26
- this.eq = null
27
- }
28
- }
29
-
30
- init() {
31
- if (!this.enabled) return
32
- if (this.eq) return
33
-
34
- try {
35
- this.eq = new QueueManager((events) => this.sendEvents(events), {
36
- flushInterval: 10000,
37
- })
38
-
39
- if (!this.beforeUnloadAttached) {
40
- this.beforeUnloadAttached = true
41
-
42
- // Send remaining events on unload
43
- window.addEventListener('beforeunload', () => {
44
- const events = this.eq?.getBufferedEvents?.() || []
45
- if (events.length) this.sendBeacon(events)
46
- })
47
- }
48
- } catch (error: any) {
49
- // ignore errors
50
- }
51
- }
52
-
53
- capture(event: string, app: string, props?: Record<string, any>) {
54
- if (!this.enabled || !this.eq) return
55
-
56
- const user = (window as any)?.frappe?.session?.user
57
-
58
- this.eq.add({
59
- event_name: event,
60
- app: app,
61
- properties: props,
62
- user,
63
- captured_at: new Date().toISOString(),
64
- })
65
- }
66
-
67
- private async sendEvents(events: PulseEvent[]): Promise<void> {
68
- return call('frappe.utils.telemetry.pulse.client.bulk_capture', { events })
69
- }
70
-
71
- private sendBeacon(events: PulseEvent[]) {
72
- try {
73
- if (navigator.sendBeacon) {
74
- const url =
75
- '/api/method/frappe.utils.telemetry.pulse.client.bulk_capture'
76
- const data = new FormData()
77
- data.append('events', JSON.stringify(events))
78
- navigator.sendBeacon(url, data)
79
- }
80
- } catch (error: any) {
81
- // ignore errors
82
- }
83
- }
84
-
85
- stop() {
86
- this.eq?.stop()
87
- this.eq = null
88
- }
89
- }
90
-
91
- type QueueManagerOptions = {
92
- maxQueueSize?: number
12
+ // "cookieless" (default): host derives the anon id at ingest. "client": mints a
13
+ // persistent localStorage anon id.
14
+ export type PulseAnonymousMode = 'cookieless' | 'client'
15
+
16
+ export interface PulseClientOptions {
17
+ host?: string
18
+ apiKey?: string
19
+ site?: string
20
+ enabled?: boolean
21
+ getContext?: () => PulseContext
22
+ anonymousMode?: PulseAnonymousMode
93
23
  flushInterval?: number
24
+ maxQueueSize?: number
25
+ now?: () => string
26
+ clientUrl?: string
94
27
  }
95
28
 
96
- class QueueManager {
97
- private flushCallback: (events: PulseEvent[]) => Promise<void>
98
- private queue: PulseEvent[]
99
- private pendingBatch: PulseEvent[] | null
100
- private retryAttempts: number
101
- private maxRetries: number
102
- private maxQueueSize: number
103
- private flushInterval: number
104
- private timer: ReturnType<typeof setInterval> | null
105
- private flushing: boolean
106
-
107
- constructor(
108
- flushCallback: (events: PulseEvent[]) => Promise<void>,
109
- options: QueueManagerOptions = {},
110
- ) {
111
- this.flushCallback = flushCallback
112
- this.queue = []
113
- this.pendingBatch = null
114
- this.retryAttempts = 0
115
- this.maxRetries = 3
116
- this.maxQueueSize = options.maxQueueSize || 20
117
- this.flushInterval = options.flushInterval || 5000
118
- this.timer = null
119
- this.flushing = false
120
-
121
- this.start()
122
- }
123
-
124
- getBufferedEvents() {
125
- const events: PulseEvent[] = []
126
- if (this.pendingBatch?.length) events.push(...this.pendingBatch)
127
- if (this.queue.length) events.push(...this.queue)
128
- return events
129
- }
29
+ export interface PulseClient {
30
+ init(): Promise<boolean>
31
+ setEnabled(enabled: boolean): void
32
+ capture(event_name: string, app: string, props?: Record<string, any>): void
33
+ // Distinct id on events (known identity, else anon id); used to alias() pre-signup
34
+ // activity. Optional: an older CDN build may not expose it.
35
+ getDistinctId?(): string
36
+ flush(): Promise<void> | undefined
37
+ stop(): void
38
+ }
130
39
 
131
- private start() {
132
- this.timer = setInterval(() => {
133
- if (this.queue.length || this.pendingBatch) this.flush()
134
- }, this.flushInterval)
135
- }
40
+ // Shape returned by the framework's whitelisted boot_config method.
41
+ export interface BootConfig {
42
+ enabled?: boolean
43
+ host?: string
44
+ key?: string
45
+ site?: string
46
+ user?: string
47
+ team?: string
48
+ client_url?: string
49
+ site_age?: number
50
+ }
136
51
 
137
- add(event: PulseEvent) {
138
- this.queue.push(event)
52
+ export const BOOT_CONFIG_METHOD = 'frappe.utils.telemetry.pulse.client.boot_config'
139
53
 
140
- if (this.queue.length >= this.maxQueueSize) {
141
- this.flush()
142
- }
54
+ // Direct-mode config from the app's own backend (frappe-ui SPAs lack desk's
55
+ // window.frappe.boot). Degrades to {} on any error, including old backends (404).
56
+ export async function fetchBootConfig(): Promise<BootConfig> {
57
+ try {
58
+ return (await call(BOOT_CONFIG_METHOD)) || {}
59
+ } catch (error) {
60
+ return {}
143
61
  }
62
+ }
144
63
 
145
- async flush() {
146
- if (this.flushing) return
147
- this.flushing = true
148
-
64
+ export const DEFAULT_PULSE_CLIENT_URL =
65
+ 'https://pulse.m.frappe.cloud/assets/pulse/js/pulse_client.js'
66
+
67
+ // Only import from a trusted origin: pulse's CDN or the ingest host (self-hosted
68
+ // pulse). Stops a tampered boot_config from redirecting import() to an attacker
69
+ // origin — an untrusted url falls back to the canonical CDN.
70
+ function trustedClientUrl(clientUrl: string | undefined, host?: string): string {
71
+ if (!clientUrl) return DEFAULT_PULSE_CLIENT_URL
72
+ try {
73
+ const target = new URL(clientUrl)
74
+ if (target.protocol !== 'https:') return DEFAULT_PULSE_CLIENT_URL
75
+ const allowed = new Set([new URL(DEFAULT_PULSE_CLIENT_URL).origin])
149
76
  try {
150
- if (!this.pendingBatch) {
151
- if (!this.queue.length) return
152
- this.pendingBatch = this.queue.splice(0, this.maxQueueSize)
153
- this.retryAttempts = 0
154
- }
155
-
156
- try {
157
- await this.flushCallback(this.pendingBatch)
158
- this.pendingBatch = null
159
- this.retryAttempts = 0
160
- } catch (error: any) {
161
- this.retryAttempts++
162
- if (this.retryAttempts > this.maxRetries) {
163
- this.pendingBatch = null
164
- this.retryAttempts = 0
165
- }
166
- }
167
- } finally {
168
- this.flushing = false
169
- }
170
- }
77
+ if (host) allowed.add(new URL(host).origin)
78
+ } catch {}
79
+ if (allowed.has(target.origin)) return clientUrl
80
+ } catch {}
81
+ return DEFAULT_PULSE_CLIENT_URL
82
+ }
171
83
 
172
- stop() {
173
- if (this.timer) {
174
- clearInterval(this.timer)
175
- this.timer = null
176
- }
177
- void this.flush()
84
+ export async function loadPulseClient(
85
+ options: PulseClientOptions = {},
86
+ ): Promise<PulseClient | null> {
87
+ const { clientUrl, ...clientOptions } = options
88
+ try {
89
+ const url = trustedClientUrl(clientUrl, clientOptions.host)
90
+ const mod = await import(/* @vite-ignore */ url)
91
+ return new mod.PulseClient(clientOptions) as PulseClient
92
+ } catch (error) {
93
+ return null
178
94
  }
179
95
  }
180
-
181
- export const pulse_provider = new PulseProvider()
@@ -2,86 +2,20 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
 
5
- import { beforeEach, describe, expect, it, vi } from 'vitest'
6
- import { PulseProvider } from './pulse'
7
-
8
- vi.mock('frappe-ui', () => ({
9
- call: vi.fn().mockResolvedValue({}),
10
- }))
11
-
12
- describe('PulseProvider', () => {
13
- let provider: PulseProvider
14
-
15
- beforeEach(() => {
16
- provider = new PulseProvider()
17
- })
18
-
19
- it('is disabled by default', () => {
20
- const result = provider.capture('test', 'app')
21
- expect(result).toBeUndefined()
22
- })
23
-
24
- it('captures events after being enabled and initialized', () => {
25
- provider.setEnabled(true)
26
- provider.init()
27
-
28
- // Should not throw
29
- expect(() => {
30
- provider.capture('user_login', 'test_app', { user: 'test@example.com' })
31
- }).not.toThrow()
32
- })
33
-
34
- it('stops capturing events when disabled', () => {
35
- provider.setEnabled(true)
36
- provider.init()
37
- provider.capture('event1', 'app')
38
-
39
- provider.setEnabled(false)
40
-
41
- // Should not throw
42
- expect(() => {
43
- provider.capture('event2', 'app')
44
- }).not.toThrow()
5
+ import { describe, expect, it } from 'vitest'
6
+ import { loadPulseClient, fetchBootConfig } from './pulse'
7
+
8
+ // The queue / transport / capture logic lives in the canonical pulse client,
9
+ // loaded at runtime from pulse's CDN (version-independent of the frappe backend).
10
+ // frappe-ui's only job is to load it and its config and degrade gracefully when
11
+ // neither is available — which is exactly the case in the test environment, where
12
+ // the remote asset URL and the backend method can't be resolved.
13
+ describe('telemetry plugin', () => {
14
+ it('degrades to null when the client asset cannot be loaded', async () => {
15
+ expect(await loadPulseClient()).toBeNull()
45
16
  })
46
17
 
47
- it('sends events to correct API endpoint', async () => {
48
- const { call } = await import('frappe-ui')
49
- const mockCall = call as any
50
-
51
- provider.setEnabled(true)
52
- provider.init()
53
-
54
- const testEvents = [
55
- {
56
- event_name: 'test_event',
57
- app: 'test_app',
58
- captured_at: new Date().toISOString(),
59
- },
60
- ]
61
-
62
- await provider['sendEvents'](testEvents)
63
-
64
- expect(mockCall).toHaveBeenCalledWith(
65
- 'frappe.utils.telemetry.pulse.client.bulk_capture',
66
- { events: testEvents },
67
- )
68
- })
69
-
70
- it('batches events before sending', () => {
71
- vi.useFakeTimers()
72
-
73
- provider.setEnabled(true)
74
- provider.init()
75
-
76
- // Capture multiple events
77
- for (let i = 0; i < 5; i++) {
78
- provider.capture('event', 'app', { count: i })
79
- }
80
-
81
- // Events should be queued, not sent immediately
82
- const buffered = provider['eq']?.getBufferedEvents()
83
- expect(buffered?.length).toBeGreaterThan(0)
84
-
85
- vi.useRealTimers()
18
+ it('degrades to {} when the backend config is unreachable (e.g. old framework)', async () => {
19
+ expect(await fetchBootConfig()).toEqual({})
86
20
  })
87
21
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.18",
3
+ "version": "1.0.0-beta.19",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -1,15 +0,0 @@
1
- import { call } from 'frappe-ui'
2
-
3
- export const silentCall = <T>(method: string): Promise<T> => {
4
- // To prevent console errors/logs from being shown
5
- // when method doesn't exist in older versions of Frappe
6
- const originalError = console.error
7
- const originalLog = console.log
8
- console.error = () => {}
9
- console.log = () => {}
10
-
11
- return call(method).catch(() => {}).finally(() => {
12
- console.log = originalLog
13
- console.error = originalError
14
- })
15
- }