@loamly/tracker 1.8.0 → 2.0.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/README.md +146 -47
- package/dist/index.cjs +1244 -357
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +78 -65
- package/dist/index.d.ts +78 -65
- package/dist/index.mjs +1244 -357
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1291 -140
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +16 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +2 -2
- package/src/behavioral/form-tracker.ts +325 -0
- package/src/behavioral/index.ts +9 -0
- package/src/behavioral/scroll-tracker.ts +163 -0
- package/src/behavioral/time-tracker.ts +174 -0
- package/src/browser.ts +127 -36
- package/src/config.ts +1 -1
- package/src/core.ts +278 -156
- package/src/infrastructure/event-queue.ts +225 -0
- package/src/infrastructure/index.ts +8 -0
- package/src/infrastructure/ping.ts +149 -0
- package/src/spa/index.ts +7 -0
- package/src/spa/router.ts +147 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Queue with Batching, Retry & Persistence
|
|
3
|
+
*
|
|
4
|
+
* Production-grade event delivery with:
|
|
5
|
+
* - Batch processing for network efficiency
|
|
6
|
+
* - Retry with exponential backoff
|
|
7
|
+
* - localStorage persistence for offline support
|
|
8
|
+
* - sendBeacon fallback for unload events
|
|
9
|
+
*
|
|
10
|
+
* @module @loamly/tracker
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_CONFIG } from '../config'
|
|
14
|
+
|
|
15
|
+
export interface QueuedEvent {
|
|
16
|
+
id: string
|
|
17
|
+
type: string
|
|
18
|
+
payload: Record<string, unknown>
|
|
19
|
+
timestamp: number
|
|
20
|
+
retries: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface EventQueueConfig {
|
|
24
|
+
batchSize: number
|
|
25
|
+
batchTimeout: number
|
|
26
|
+
maxRetries: number
|
|
27
|
+
retryDelayMs: number
|
|
28
|
+
storageKey: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_QUEUE_CONFIG: EventQueueConfig = {
|
|
32
|
+
batchSize: DEFAULT_CONFIG.batchSize,
|
|
33
|
+
batchTimeout: DEFAULT_CONFIG.batchTimeout,
|
|
34
|
+
maxRetries: 3,
|
|
35
|
+
retryDelayMs: 1000,
|
|
36
|
+
storageKey: '_loamly_queue',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class EventQueue {
|
|
40
|
+
private queue: QueuedEvent[] = []
|
|
41
|
+
private batchTimer: ReturnType<typeof setTimeout> | null = null
|
|
42
|
+
private config: EventQueueConfig
|
|
43
|
+
private endpoint: string
|
|
44
|
+
private isFlushing = false
|
|
45
|
+
|
|
46
|
+
constructor(endpoint: string, config: Partial<EventQueueConfig> = {}) {
|
|
47
|
+
this.endpoint = endpoint
|
|
48
|
+
this.config = { ...DEFAULT_QUEUE_CONFIG, ...config }
|
|
49
|
+
this.loadFromStorage()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Add event to queue
|
|
54
|
+
*/
|
|
55
|
+
push(type: string, payload: Record<string, unknown>): void {
|
|
56
|
+
const event: QueuedEvent = {
|
|
57
|
+
id: this.generateId(),
|
|
58
|
+
type,
|
|
59
|
+
payload,
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
retries: 0,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.queue.push(event)
|
|
65
|
+
this.saveToStorage()
|
|
66
|
+
this.scheduleBatch()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Force flush all events immediately
|
|
71
|
+
*/
|
|
72
|
+
async flush(): Promise<void> {
|
|
73
|
+
if (this.isFlushing || this.queue.length === 0) return
|
|
74
|
+
|
|
75
|
+
this.isFlushing = true
|
|
76
|
+
this.clearBatchTimer()
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const events = [...this.queue]
|
|
80
|
+
this.queue = []
|
|
81
|
+
|
|
82
|
+
await this.sendBatch(events)
|
|
83
|
+
} finally {
|
|
84
|
+
this.isFlushing = false
|
|
85
|
+
this.saveToStorage()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Flush using sendBeacon (for unload events)
|
|
91
|
+
*/
|
|
92
|
+
flushBeacon(): boolean {
|
|
93
|
+
if (this.queue.length === 0) return true
|
|
94
|
+
|
|
95
|
+
const events = this.queue.map(e => ({
|
|
96
|
+
type: e.type,
|
|
97
|
+
...e.payload,
|
|
98
|
+
_queue_id: e.id,
|
|
99
|
+
_queue_timestamp: e.timestamp,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const success = navigator.sendBeacon?.(
|
|
103
|
+
this.endpoint,
|
|
104
|
+
JSON.stringify({ events, beacon: true })
|
|
105
|
+
) ?? false
|
|
106
|
+
|
|
107
|
+
if (success) {
|
|
108
|
+
this.queue = []
|
|
109
|
+
this.clearStorage()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return success
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get current queue length
|
|
117
|
+
*/
|
|
118
|
+
get length(): number {
|
|
119
|
+
return this.queue.length
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private scheduleBatch(): void {
|
|
123
|
+
if (this.batchTimer) return
|
|
124
|
+
|
|
125
|
+
// Flush immediately if batch size reached
|
|
126
|
+
if (this.queue.length >= this.config.batchSize) {
|
|
127
|
+
this.flush()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Schedule batch flush
|
|
132
|
+
this.batchTimer = setTimeout(() => {
|
|
133
|
+
this.batchTimer = null
|
|
134
|
+
this.flush()
|
|
135
|
+
}, this.config.batchTimeout)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private clearBatchTimer(): void {
|
|
139
|
+
if (this.batchTimer) {
|
|
140
|
+
clearTimeout(this.batchTimer)
|
|
141
|
+
this.batchTimer = null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async sendBatch(events: QueuedEvent[]): Promise<void> {
|
|
146
|
+
if (events.length === 0) return
|
|
147
|
+
|
|
148
|
+
const payload = {
|
|
149
|
+
events: events.map(e => ({
|
|
150
|
+
type: e.type,
|
|
151
|
+
...e.payload,
|
|
152
|
+
_queue_id: e.id,
|
|
153
|
+
_queue_timestamp: e.timestamp,
|
|
154
|
+
})),
|
|
155
|
+
batch: true,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(this.endpoint, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify(payload),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
throw new Error(`HTTP ${response.status}`)
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Retry failed events
|
|
170
|
+
for (const event of events) {
|
|
171
|
+
if (event.retries < this.config.maxRetries) {
|
|
172
|
+
event.retries++
|
|
173
|
+
this.queue.push(event)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Schedule retry with exponential backoff
|
|
178
|
+
if (this.queue.length > 0) {
|
|
179
|
+
const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1)
|
|
180
|
+
setTimeout(() => this.flush(), delay)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private loadFromStorage(): void {
|
|
186
|
+
try {
|
|
187
|
+
const stored = localStorage.getItem(this.config.storageKey)
|
|
188
|
+
if (stored) {
|
|
189
|
+
const parsed = JSON.parse(stored)
|
|
190
|
+
if (Array.isArray(parsed)) {
|
|
191
|
+
// Only restore events less than 24 hours old
|
|
192
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
|
193
|
+
this.queue = parsed.filter((e: QueuedEvent) => e.timestamp > cutoff)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// localStorage not available or corrupted
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private saveToStorage(): void {
|
|
202
|
+
try {
|
|
203
|
+
if (this.queue.length > 0) {
|
|
204
|
+
localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue))
|
|
205
|
+
} else {
|
|
206
|
+
this.clearStorage()
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// localStorage not available
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private clearStorage(): void {
|
|
214
|
+
try {
|
|
215
|
+
localStorage.removeItem(this.config.storageKey)
|
|
216
|
+
} catch {
|
|
217
|
+
// Ignore
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private generateId(): string {
|
|
222
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-time Ping Service
|
|
3
|
+
*
|
|
4
|
+
* Heartbeat for active session tracking with:
|
|
5
|
+
* - Configurable interval (default 30s)
|
|
6
|
+
* - Visibility-aware (pauses when tab hidden)
|
|
7
|
+
* - Session metrics aggregation
|
|
8
|
+
*
|
|
9
|
+
* @module @loamly/tracker
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEFAULT_CONFIG } from '../config'
|
|
13
|
+
|
|
14
|
+
export interface PingData {
|
|
15
|
+
session_id: string
|
|
16
|
+
visitor_id: string
|
|
17
|
+
url: string
|
|
18
|
+
time_on_page_ms: number
|
|
19
|
+
scroll_depth: number
|
|
20
|
+
is_active: boolean
|
|
21
|
+
tracker_version: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PingConfig {
|
|
25
|
+
interval: number
|
|
26
|
+
endpoint: string
|
|
27
|
+
onPing?: (data: PingData) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PingService {
|
|
31
|
+
private intervalId: ReturnType<typeof setInterval> | null = null
|
|
32
|
+
private config: PingConfig
|
|
33
|
+
private pageLoadTime: number
|
|
34
|
+
private isVisible = true
|
|
35
|
+
private currentScrollDepth = 0
|
|
36
|
+
private sessionId: string
|
|
37
|
+
private visitorId: string
|
|
38
|
+
private version: string
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
sessionId: string,
|
|
42
|
+
visitorId: string,
|
|
43
|
+
version: string,
|
|
44
|
+
config: Partial<PingConfig> = {}
|
|
45
|
+
) {
|
|
46
|
+
this.sessionId = sessionId
|
|
47
|
+
this.visitorId = visitorId
|
|
48
|
+
this.version = version
|
|
49
|
+
this.pageLoadTime = Date.now()
|
|
50
|
+
this.config = {
|
|
51
|
+
interval: DEFAULT_CONFIG.pingInterval,
|
|
52
|
+
endpoint: '',
|
|
53
|
+
...config,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Track visibility
|
|
57
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
|
58
|
+
|
|
59
|
+
// Track scroll depth
|
|
60
|
+
window.addEventListener('scroll', this.handleScroll, { passive: true })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start the ping service
|
|
65
|
+
*/
|
|
66
|
+
start(): void {
|
|
67
|
+
if (this.intervalId) return
|
|
68
|
+
|
|
69
|
+
this.intervalId = setInterval(() => {
|
|
70
|
+
if (this.isVisible) {
|
|
71
|
+
this.ping()
|
|
72
|
+
}
|
|
73
|
+
}, this.config.interval)
|
|
74
|
+
|
|
75
|
+
// Send initial ping
|
|
76
|
+
this.ping()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stop the ping service
|
|
81
|
+
*/
|
|
82
|
+
stop(): void {
|
|
83
|
+
if (this.intervalId) {
|
|
84
|
+
clearInterval(this.intervalId)
|
|
85
|
+
this.intervalId = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
|
89
|
+
window.removeEventListener('scroll', this.handleScroll)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Update scroll depth (called by external scroll tracker)
|
|
94
|
+
*/
|
|
95
|
+
updateScrollDepth(depth: number): void {
|
|
96
|
+
if (depth > this.currentScrollDepth) {
|
|
97
|
+
this.currentScrollDepth = depth
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get current ping data
|
|
103
|
+
*/
|
|
104
|
+
getData(): PingData {
|
|
105
|
+
return {
|
|
106
|
+
session_id: this.sessionId,
|
|
107
|
+
visitor_id: this.visitorId,
|
|
108
|
+
url: window.location.href,
|
|
109
|
+
time_on_page_ms: Date.now() - this.pageLoadTime,
|
|
110
|
+
scroll_depth: this.currentScrollDepth,
|
|
111
|
+
is_active: this.isVisible,
|
|
112
|
+
tracker_version: this.version,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private ping = async (): Promise<void> => {
|
|
117
|
+
const data = this.getData()
|
|
118
|
+
|
|
119
|
+
// Call callback if provided
|
|
120
|
+
this.config.onPing?.(data)
|
|
121
|
+
|
|
122
|
+
// Send to endpoint if configured
|
|
123
|
+
if (this.config.endpoint) {
|
|
124
|
+
try {
|
|
125
|
+
await fetch(this.config.endpoint, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify(data),
|
|
129
|
+
})
|
|
130
|
+
} catch {
|
|
131
|
+
// Ping failures are silent
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private handleVisibilityChange = (): void => {
|
|
137
|
+
this.isVisible = document.visibilityState === 'visible'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private handleScroll = (): void => {
|
|
141
|
+
const scrollPercent = Math.round(
|
|
142
|
+
((window.scrollY + window.innerHeight) / document.documentElement.scrollHeight) * 100
|
|
143
|
+
)
|
|
144
|
+
if (scrollPercent > this.currentScrollDepth) {
|
|
145
|
+
this.currentScrollDepth = Math.min(scrollPercent, 100)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
package/src/spa/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPA Navigation Router
|
|
3
|
+
*
|
|
4
|
+
* Detects client-side navigation in SPAs with support for:
|
|
5
|
+
* - History API (pushState, replaceState, popstate)
|
|
6
|
+
* - Hash changes (for hash-based routing)
|
|
7
|
+
* - Next.js, React Router, Vue Router, etc.
|
|
8
|
+
*
|
|
9
|
+
* @module @loamly/tracker
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface NavigationEvent {
|
|
13
|
+
from_url: string
|
|
14
|
+
to_url: string
|
|
15
|
+
navigation_type: 'push' | 'replace' | 'pop' | 'hash' | 'initial'
|
|
16
|
+
time_on_previous_page_ms: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouterConfig {
|
|
20
|
+
onNavigate?: (event: NavigationEvent) => void
|
|
21
|
+
ignoreHashChange?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SPARouter {
|
|
25
|
+
private config: RouterConfig
|
|
26
|
+
private currentUrl: string
|
|
27
|
+
private pageEnterTime: number
|
|
28
|
+
private originalPushState: typeof history.pushState | null = null
|
|
29
|
+
private originalReplaceState: typeof history.replaceState | null = null
|
|
30
|
+
|
|
31
|
+
constructor(config: RouterConfig = {}) {
|
|
32
|
+
this.config = config
|
|
33
|
+
this.currentUrl = window.location.href
|
|
34
|
+
this.pageEnterTime = Date.now()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Start listening for navigation events
|
|
39
|
+
*/
|
|
40
|
+
start(): void {
|
|
41
|
+
// Patch History API
|
|
42
|
+
this.patchHistoryAPI()
|
|
43
|
+
|
|
44
|
+
// Listen for popstate (back/forward)
|
|
45
|
+
window.addEventListener('popstate', this.handlePopState)
|
|
46
|
+
|
|
47
|
+
// Listen for hash changes
|
|
48
|
+
if (!this.config.ignoreHashChange) {
|
|
49
|
+
window.addEventListener('hashchange', this.handleHashChange)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stop listening and restore original methods
|
|
55
|
+
*/
|
|
56
|
+
stop(): void {
|
|
57
|
+
// Restore original History API methods
|
|
58
|
+
if (this.originalPushState) {
|
|
59
|
+
history.pushState = this.originalPushState
|
|
60
|
+
}
|
|
61
|
+
if (this.originalReplaceState) {
|
|
62
|
+
history.replaceState = this.originalReplaceState
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
window.removeEventListener('popstate', this.handlePopState)
|
|
66
|
+
window.removeEventListener('hashchange', this.handleHashChange)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Manually trigger a navigation event (for custom routers)
|
|
71
|
+
*/
|
|
72
|
+
navigate(url: string, type: NavigationEvent['navigation_type'] = 'push'): void {
|
|
73
|
+
this.emitNavigation(url, type)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get current URL
|
|
78
|
+
*/
|
|
79
|
+
getCurrentUrl(): string {
|
|
80
|
+
return this.currentUrl
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get time on current page
|
|
85
|
+
*/
|
|
86
|
+
getTimeOnPage(): number {
|
|
87
|
+
return Date.now() - this.pageEnterTime
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private patchHistoryAPI(): void {
|
|
91
|
+
// Store original methods
|
|
92
|
+
this.originalPushState = history.pushState.bind(history)
|
|
93
|
+
this.originalReplaceState = history.replaceState.bind(history)
|
|
94
|
+
|
|
95
|
+
// Patch pushState
|
|
96
|
+
history.pushState = (...args) => {
|
|
97
|
+
const result = this.originalPushState!(...args)
|
|
98
|
+
this.handleStateChange('push')
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Patch replaceState
|
|
103
|
+
history.replaceState = (...args) => {
|
|
104
|
+
const result = this.originalReplaceState!(...args)
|
|
105
|
+
this.handleStateChange('replace')
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private handleStateChange = (type: 'push' | 'replace'): void => {
|
|
111
|
+
const newUrl = window.location.href
|
|
112
|
+
if (newUrl !== this.currentUrl) {
|
|
113
|
+
this.emitNavigation(newUrl, type)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private handlePopState = (): void => {
|
|
118
|
+
const newUrl = window.location.href
|
|
119
|
+
if (newUrl !== this.currentUrl) {
|
|
120
|
+
this.emitNavigation(newUrl, 'pop')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private handleHashChange = (): void => {
|
|
125
|
+
const newUrl = window.location.href
|
|
126
|
+
if (newUrl !== this.currentUrl) {
|
|
127
|
+
this.emitNavigation(newUrl, 'hash')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private emitNavigation(toUrl: string, type: NavigationEvent['navigation_type']): void {
|
|
132
|
+
const event: NavigationEvent = {
|
|
133
|
+
from_url: this.currentUrl,
|
|
134
|
+
to_url: toUrl,
|
|
135
|
+
navigation_type: type,
|
|
136
|
+
time_on_previous_page_ms: Date.now() - this.pageEnterTime,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update current state
|
|
140
|
+
this.currentUrl = toUrl
|
|
141
|
+
this.pageEnterTime = Date.now()
|
|
142
|
+
|
|
143
|
+
// Emit event
|
|
144
|
+
this.config.onNavigate?.(event)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|