@vircle/sdk-web 0.2.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.
@@ -0,0 +1,1472 @@
1
+ import { VircleCore, WebIdleScheduler } from '@vircle/sdk-core-ts';
2
+
3
+ /**
4
+ * 웹 브라우저 환경 정보 수집기
5
+ */
6
+ class WebContextCollector {
7
+ /**
8
+ * 전체 컨텍스트 수집
9
+ * @returns 수집된 컨텍스트
10
+ */
11
+ async collect() {
12
+ const [device, page, app] = await Promise.all([
13
+ this.collectDeviceContext(),
14
+ this.collectPageContext(),
15
+ this.collectAppContext(),
16
+ ]);
17
+ return {
18
+ device,
19
+ page,
20
+ app,
21
+ custom: this.collectCustomContext(),
22
+ };
23
+ }
24
+ /**
25
+ * 디바이스 정보 수집
26
+ */
27
+ async collectDeviceContext() {
28
+ const ua = navigator.userAgent;
29
+ const platform = navigator.platform || 'unknown';
30
+ return {
31
+ type: this.getDeviceType(ua),
32
+ os: this.getOS(ua, platform),
33
+ osVersion: this.getOSVersion(ua),
34
+ browser: this.getBrowser(ua),
35
+ browserVersion: this.getBrowserVersion(ua),
36
+ screenResolution: `${screen.width}x${screen.height}`,
37
+ viewport: {
38
+ width: window.innerWidth,
39
+ height: window.innerHeight,
40
+ },
41
+ language: navigator.language,
42
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
43
+ };
44
+ }
45
+ /**
46
+ * 페이지 정보 수집
47
+ */
48
+ async collectPageContext() {
49
+ const url = new URL(window.location.href);
50
+ return {
51
+ url: url.href,
52
+ title: document.title,
53
+ path: url.pathname,
54
+ search: url.search,
55
+ hash: url.hash,
56
+ };
57
+ }
58
+ /**
59
+ * 앱 정보 수집
60
+ */
61
+ async collectAppContext() {
62
+ return {
63
+ name: this.getAppName(),
64
+ version: this.getAppVersion(),
65
+ environment: this.getEnvironment(),
66
+ };
67
+ }
68
+ /**
69
+ * 커스텀 컨텍스트 수집
70
+ */
71
+ collectCustomContext() {
72
+ const context = {};
73
+ // Referrer 정보
74
+ if (document.referrer) {
75
+ context.referrer = document.referrer;
76
+ }
77
+ // UTM 파라미터 추출
78
+ const url = new URL(window.location.href);
79
+ const campaign = this.extractCampaignParams(url.searchParams);
80
+ if (campaign) {
81
+ context.campaign = campaign;
82
+ }
83
+ // 쿠키 활성화 여부
84
+ context.cookieEnabled = navigator.cookieEnabled;
85
+ // Do Not Track 설정
86
+ if (navigator.doNotTrack) {
87
+ context.doNotTrack = navigator.doNotTrack === '1';
88
+ }
89
+ const nav = navigator;
90
+ if (nav.connection) {
91
+ context.connection = {
92
+ effectiveType: nav.connection.effectiveType,
93
+ downlink: nav.connection.downlink,
94
+ rtt: nav.connection.rtt,
95
+ saveData: nav.connection.saveData,
96
+ };
97
+ }
98
+ const perf = performance;
99
+ if (perf.memory) {
100
+ context.memory = {
101
+ usedJSHeapSize: perf.memory.usedJSHeapSize,
102
+ totalJSHeapSize: perf.memory.totalJSHeapSize,
103
+ jsHeapSizeLimit: perf.memory.jsHeapSizeLimit,
104
+ };
105
+ }
106
+ return context;
107
+ }
108
+ /**
109
+ * 디바이스 타입 판별
110
+ */
111
+ getDeviceType(ua) {
112
+ if (/mobile/i.test(ua) && !/ipad/i.test(ua)) {
113
+ return 'mobile';
114
+ }
115
+ if (/ipad|tablet|playbook|silk/i.test(ua)) {
116
+ return 'tablet';
117
+ }
118
+ return 'desktop';
119
+ }
120
+ /**
121
+ * OS 판별
122
+ */
123
+ getOS(ua, platform) {
124
+ if (/windows/i.test(ua))
125
+ return 'Windows';
126
+ if (/macintosh|mac os x/i.test(ua))
127
+ return 'macOS';
128
+ if (/linux/i.test(ua))
129
+ return 'Linux';
130
+ if (/android/i.test(ua))
131
+ return 'Android';
132
+ if (/iphone|ipad|ipod/i.test(ua))
133
+ return 'iOS';
134
+ if (/cros/i.test(ua))
135
+ return 'Chrome OS';
136
+ return platform;
137
+ }
138
+ /**
139
+ * OS 버전 추출
140
+ */
141
+ getOSVersion(ua) {
142
+ let version = null;
143
+ if (/windows nt (\d+\.\d+)/i.test(ua)) {
144
+ version = ua.match(/windows nt (\d+\.\d+)/i);
145
+ }
146
+ else if (/mac os x (\d+[._]\d+)/i.test(ua)) {
147
+ version = ua.match(/mac os x (\d+[._]\d+)/i);
148
+ }
149
+ else if (/android (\d+\.\d+)/i.test(ua)) {
150
+ version = ua.match(/android (\d+\.\d+)/i);
151
+ }
152
+ else if (/os (\d+[._]\d+)/i.test(ua)) {
153
+ version = ua.match(/os (\d+[._]\d+)/i);
154
+ }
155
+ return version ? version[1].replace(/_/g, '.') : undefined;
156
+ }
157
+ /**
158
+ * 브라우저 판별
159
+ */
160
+ getBrowser(ua) {
161
+ if (/edg/i.test(ua))
162
+ return 'Edge';
163
+ if (/chrome|chromium|crios/i.test(ua))
164
+ return 'Chrome';
165
+ if (/firefox|fxios/i.test(ua))
166
+ return 'Firefox';
167
+ if (/safari/i.test(ua) && !/chrome/i.test(ua))
168
+ return 'Safari';
169
+ if (/opr|opera/i.test(ua))
170
+ return 'Opera';
171
+ if (/trident/i.test(ua))
172
+ return 'IE';
173
+ return 'Unknown';
174
+ }
175
+ /**
176
+ * 브라우저 버전 추출
177
+ */
178
+ getBrowserVersion(ua) {
179
+ let version = null;
180
+ if (/edg\/(\d+\.\d+)/i.test(ua)) {
181
+ version = ua.match(/edg\/(\d+\.\d+)/i);
182
+ }
183
+ else if (/chrome\/(\d+\.\d+)/i.test(ua)) {
184
+ version = ua.match(/chrome\/(\d+\.\d+)/i);
185
+ }
186
+ else if (/firefox\/(\d+\.\d+)/i.test(ua)) {
187
+ version = ua.match(/firefox\/(\d+\.\d+)/i);
188
+ }
189
+ else if (/version\/(\d+\.\d+).*safari/i.test(ua)) {
190
+ version = ua.match(/version\/(\d+\.\d+).*safari/i);
191
+ }
192
+ else if (/opr\/(\d+\.\d+)/i.test(ua)) {
193
+ version = ua.match(/opr\/(\d+\.\d+)/i);
194
+ }
195
+ return version ? version[1] : undefined;
196
+ }
197
+ /**
198
+ * UTM 캠페인 파라미터 추출
199
+ */
200
+ extractCampaignParams(params) {
201
+ const campaign = {};
202
+ const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
203
+ let hasParams = false;
204
+ for (const param of utmParams) {
205
+ const value = params.get(param);
206
+ if (value) {
207
+ campaign[param.replace('utm_', '')] = value;
208
+ hasParams = true;
209
+ }
210
+ }
211
+ return hasParams ? campaign : undefined;
212
+ }
213
+ /**
214
+ * 앱 이름 가져오기 (meta 태그에서)
215
+ */
216
+ getAppName() {
217
+ const appNameMeta = document.querySelector('meta[name="application-name"]');
218
+ const ogSiteName = document.querySelector('meta[property="og:site_name"]');
219
+ return (appNameMeta?.content ||
220
+ ogSiteName?.content ||
221
+ document.title ||
222
+ 'Web App');
223
+ }
224
+ /**
225
+ * 앱 버전 가져오기 (meta 태그에서)
226
+ */
227
+ getAppVersion() {
228
+ const versionMeta = document.querySelector('meta[name="version"]');
229
+ return versionMeta?.content || undefined;
230
+ }
231
+ /**
232
+ * 환경 판별
233
+ */
234
+ getEnvironment() {
235
+ const hostname = window.location.hostname;
236
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.includes('.local')) {
237
+ return 'development';
238
+ }
239
+ // Staging/test environments are considered as development
240
+ if (hostname.includes('staging') || hostname.includes('stage') || hostname.includes('test')) {
241
+ return 'development';
242
+ }
243
+ return 'production';
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Vircle Web SDK 전용 에러 클래스
249
+ *
250
+ * @description
251
+ * Vircle Web SDK에서 발생하는 모든 에러의 기본 클래스입니다.
252
+ * 에러 코드와 상세 정보를 포함하여 디버깅을 용이하게 합니다.
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * throw new VircleWebError(
257
+ * 'API 키가 유효하지 않습니다',
258
+ * 'INVALID_API_KEY',
259
+ * { apiKey: 'xxx...xxx' }
260
+ * )
261
+ * ```
262
+ */
263
+ class VircleWebError extends Error {
264
+ constructor(message, code, details) {
265
+ super(message);
266
+ this.code = code;
267
+ this.details = details;
268
+ this.name = 'VircleWebError';
269
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
270
+ if (Error.captureStackTrace) {
271
+ Error.captureStackTrace(this, VircleWebError);
272
+ }
273
+ }
274
+ }
275
+ /**
276
+ * 설정 관련 에러
277
+ *
278
+ * @description
279
+ * SDK 설정에 문제가 있을 때 발생하는 에러입니다.
280
+ * API 키 누락, 잘못된 설정 값 등의 경우에 사용됩니다.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * if (!config.apiKey) {
285
+ * throw new VircleConfigError('API 키가 필수입니다')
286
+ * }
287
+ * ```
288
+ */
289
+ class VircleConfigError extends VircleWebError {
290
+ constructor(message, details) {
291
+ super(message, 'CONFIG_ERROR', details);
292
+ this.name = 'VircleConfigError';
293
+ }
294
+ }
295
+ /**
296
+ * 초기화 관련 에러
297
+ *
298
+ * @description
299
+ * SDK 초기화 과정에서 발생하는 에러입니다.
300
+ * 브라우저 호환성, 필수 API 지원 여부 등을 확인할 때 사용됩니다.
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * if (!window.Promise) {
305
+ * throw new VircleInitializationError(
306
+ * 'Promise API를 지원하지 않는 브라우저입니다'
307
+ * )
308
+ * }
309
+ * ```
310
+ */
311
+ class VircleInitializationError extends VircleWebError {
312
+ constructor(message, details) {
313
+ super(message, 'INITIALIZATION_ERROR', details);
314
+ this.name = 'VircleInitializationError';
315
+ }
316
+ }
317
+ /**
318
+ * 스토리지 관련 에러
319
+ *
320
+ * @description
321
+ * LocalStorage 접근 실패, 용량 초과, 직렬화 오류 등
322
+ * 스토리지 작업 중 발생하는 에러입니다.
323
+ *
324
+ * @example
325
+ * ```typescript
326
+ * try {
327
+ * localStorage.setItem(key, value)
328
+ * } catch (error) {
329
+ * throw new VircleStorageError(
330
+ * 'LocalStorage 용량 초과',
331
+ * { key, size: value.length }
332
+ * )
333
+ * }
334
+ * ```
335
+ */
336
+ class VircleStorageError extends VircleWebError {
337
+ constructor(message, details) {
338
+ super(message, 'STORAGE_ERROR', details);
339
+ this.name = 'VircleStorageError';
340
+ }
341
+ }
342
+ /**
343
+ * 브라우저 호환성 에러
344
+ *
345
+ * @description
346
+ * 현재 브라우저가 SDK에 필요한 기능을 지원하지 않을 때 발생하는 에러입니다.
347
+ * 필수 API 또는 기능이 누락된 경우에 사용됩니다.
348
+ *
349
+ * @example
350
+ * ```typescript
351
+ * if (!window.fetch) {
352
+ * throw new VircleBrowserCompatibilityError(
353
+ * 'Fetch API를 지원하지 않는 브라우저입니다',
354
+ * { userAgent: navigator.userAgent }
355
+ * )
356
+ * }
357
+ * ```
358
+ */
359
+ class VircleBrowserCompatibilityError extends VircleWebError {
360
+ constructor(message, details) {
361
+ super(message, 'BROWSER_COMPATIBILITY_ERROR', details);
362
+ this.name = 'VircleBrowserCompatibilityError';
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 브라우저 LocalStorage를 사용하는 스토리지 어댑터
368
+ *
369
+ * @description
370
+ * 브라우저의 LocalStorage API를 활용하여 데이터를 영구 저장합니다.
371
+ * LocalStorage를 사용할 수 없는 환경에서는 자동으로 대체 스토리지로 폴백합니다.
372
+ * 용량 초과 시 자동으로 오래된 항목을 정리하는 LRU 방식을 지원합니다.
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * const storage = new LocalStorageAdapter('vircle_')
377
+ * await storage.set('user_id', '12345')
378
+ * const userId = await storage.get('user_id')
379
+ * ```
380
+ */
381
+ class LocalStorageAdapter {
382
+ constructor(prefix = 'vircle_') {
383
+ this.prefix = prefix;
384
+ this.isAvailable = this.checkAvailability();
385
+ }
386
+ /**
387
+ * LocalStorage 사용 가능 여부 확인
388
+ *
389
+ * @description
390
+ * LocalStorage API의 사용 가능 여부를 테스트합니다.
391
+ * 개인정보 보호 모드나 보안 제한으로 인해 LocalStorage가 비활성화된 경우를 감지합니다.
392
+ *
393
+ * @private
394
+ * @returns {boolean} LocalStorage 사용 가능 여부
395
+ */
396
+ checkAvailability() {
397
+ try {
398
+ const testKey = `${this.prefix}test`;
399
+ localStorage.setItem(testKey, 'test');
400
+ localStorage.removeItem(testKey);
401
+ return true;
402
+ }
403
+ catch {
404
+ console.warn('[Vircle] LocalStorage를 사용할 수 없습니다. 메모리 스토리지로 대체됩니다.');
405
+ return false;
406
+ }
407
+ }
408
+ /**
409
+ * 키에 접두사 추가
410
+ *
411
+ * @description
412
+ * 키에 접두사를 추가하여 다른 애플리케이션과의 충돌을 방지합니다.
413
+ *
414
+ * @param {string} key - 원본 키
415
+ * @returns {string} 접두사가 추가된 키
416
+ * @private
417
+ */
418
+ getKey(key) {
419
+ return `${this.prefix}${key}`;
420
+ }
421
+ /**
422
+ * 값 저장
423
+ *
424
+ * @description
425
+ * LocalStorage에 값을 저장합니다. 자동으로 타임스탬프를 추가하여 LRU 정리를 지원합니다.
426
+ * 용량 초과 시 자동으로 오래된 항목을 정리한 후 재시도합니다.
427
+ *
428
+ * @example
429
+ * ```typescript
430
+ * await storage.set('preferences', { theme: 'dark', lang: 'ko' })
431
+ * ```
432
+ *
433
+ * @template T - 저장할 값의 타입
434
+ * @param {string} key - 키
435
+ * @param {T} value - 저장할 값
436
+ * @throws {VircleStorageError} 저장 실패 시
437
+ * @returns {Promise<void>}
438
+ */
439
+ async set(key, value) {
440
+ if (!this.isAvailable) {
441
+ return;
442
+ }
443
+ const item = {
444
+ value,
445
+ timestamp: Date.now(),
446
+ };
447
+ try {
448
+ const serialized = JSON.stringify(item);
449
+ localStorage.setItem(this.getKey(key), serialized);
450
+ }
451
+ catch (error) {
452
+ if (error instanceof Error && error.name === 'QuotaExceededError') {
453
+ console.warn('[Vircle] LocalStorage 용량 초과. 오래된 항목을 정리합니다.');
454
+ await this.clearOldItems();
455
+ // 재시도
456
+ try {
457
+ const serialized = JSON.stringify(item);
458
+ localStorage.setItem(this.getKey(key), serialized);
459
+ }
460
+ catch (retryError) {
461
+ throw new VircleStorageError('LocalStorage 저장 실패', {
462
+ key,
463
+ error: retryError instanceof Error ? retryError.message : String(retryError),
464
+ });
465
+ }
466
+ }
467
+ }
468
+ }
469
+ /**
470
+ * 값 가져오기
471
+ *
472
+ * @description
473
+ * LocalStorage에서 값을 가져옵니다. 이전 버전과의 호환성을 지원합니다.
474
+ *
475
+ * @example
476
+ * ```typescript
477
+ * const preferences = await storage.get<{ theme: string }>('preferences')
478
+ * if (preferences) {
479
+ * console.log(preferences.theme)
480
+ * }
481
+ * ```
482
+ *
483
+ * @template T - 반환할 값의 타입
484
+ * @param {string} key - 키
485
+ * @returns {Promise<T | null>} 저장된 값 또는 null
486
+ */
487
+ async get(key) {
488
+ if (!this.isAvailable) {
489
+ return null;
490
+ }
491
+ try {
492
+ const data = localStorage.getItem(this.getKey(key));
493
+ if (data === null) {
494
+ return null;
495
+ }
496
+ const parsed = JSON.parse(data);
497
+ // 구조가 올바른지 확인 (이전 버전 호환성)
498
+ if (parsed && typeof parsed === 'object' && 'value' in parsed && 'timestamp' in parsed) {
499
+ return parsed.value;
500
+ }
501
+ // 이전 버전 데이터 처리
502
+ return parsed;
503
+ }
504
+ catch {
505
+ return null;
506
+ }
507
+ }
508
+ /**
509
+ * 값 삭제
510
+ *
511
+ * @description
512
+ * LocalStorage에서 특정 키의 값을 삭제합니다.
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * const removed = await storage.remove('old_data')
517
+ * console.log(removed ? '삭제 성공' : '키가 존재하지 않음')
518
+ * ```
519
+ *
520
+ * @param {string} key - 키
521
+ * @returns {Promise<boolean>} 삭제 성공 여부
522
+ */
523
+ async remove(key) {
524
+ if (!this.isAvailable) {
525
+ return false;
526
+ }
527
+ try {
528
+ const fullKey = this.getKey(key);
529
+ const exists = localStorage.getItem(fullKey) !== null;
530
+ if (exists) {
531
+ localStorage.removeItem(fullKey);
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+ catch {
537
+ return false;
538
+ }
539
+ }
540
+ /**
541
+ * 모든 값 삭제
542
+ *
543
+ * @description
544
+ * 현재 접두사로 시작하는 모든 키의 값을 LocalStorage에서 삭제합니다.
545
+ * 성능 최적화를 위해 모든 키를 한 번에 가져와서 필터링합니다.
546
+ *
547
+ * @example
548
+ * ```typescript
549
+ * await storage.clear()
550
+ * console.log('모든 데이터 삭제 완료')
551
+ * ```
552
+ *
553
+ * @returns {Promise<void>}
554
+ */
555
+ async clear() {
556
+ if (!this.isAvailable) {
557
+ return;
558
+ }
559
+ try {
560
+ // localStorage의 모든 키를 한번에 가져와서 필터링 (성능 최적화)
561
+ const allKeys = Object.keys(localStorage);
562
+ const keysToRemove = allKeys.filter((key) => key.startsWith(this.prefix));
563
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
564
+ }
565
+ catch {
566
+ // 무시
567
+ }
568
+ }
569
+ /**
570
+ * 키 존재 여부 확인
571
+ *
572
+ * @description
573
+ * LocalStorage에 특정 키가 존재하는지 확인합니다.
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * if (await storage.has('user_preferences')) {
578
+ * const prefs = await storage.get('user_preferences')
579
+ * }
580
+ * ```
581
+ *
582
+ * @param {string} key - 키
583
+ * @returns {Promise<boolean>} 존재 여부
584
+ */
585
+ async has(key) {
586
+ if (!this.isAvailable) {
587
+ return false;
588
+ }
589
+ try {
590
+ return localStorage.getItem(this.getKey(key)) !== null;
591
+ }
592
+ catch {
593
+ return false;
594
+ }
595
+ }
596
+ /**
597
+ * 저장된 항목 수
598
+ *
599
+ * @description
600
+ * 현재 접두사로 저장된 모든 항목의 개수를 반환합니다.
601
+ *
602
+ * @example
603
+ * ```typescript
604
+ * const count = await storage.size()
605
+ * console.log(`저장된 항목 수: ${count}`)
606
+ * ```
607
+ *
608
+ * @returns {Promise<number>} 항목 수
609
+ */
610
+ async size() {
611
+ if (!this.isAvailable) {
612
+ return 0;
613
+ }
614
+ try {
615
+ // localStorage의 모든 키를 한번에 가져와서 필터링 (성능 최적화)
616
+ const allKeys = Object.keys(localStorage);
617
+ return allKeys.filter((key) => key.startsWith(this.prefix)).length;
618
+ }
619
+ catch {
620
+ return 0;
621
+ }
622
+ }
623
+ /**
624
+ * 모든 키 반환
625
+ *
626
+ * @description
627
+ * 현재 접두사로 저장된 모든 키를 배열로 반환합니다.
628
+ * 접두사는 제거된 원본 키를 반환합니다.
629
+ *
630
+ * @example
631
+ * ```typescript
632
+ * const allKeys = await storage.keys()
633
+ * for (const key of allKeys) {
634
+ * console.log(`Key: ${key}`)
635
+ * }
636
+ * ```
637
+ *
638
+ * @returns {Promise<string[]>} 키 배열
639
+ */
640
+ async keys() {
641
+ if (!this.isAvailable) {
642
+ return [];
643
+ }
644
+ try {
645
+ // localStorage의 모든 키를 한번에 가져와서 필터링 (성능 최적화)
646
+ const allKeys = Object.keys(localStorage);
647
+ return allKeys.filter((key) => key.startsWith(this.prefix)).map((key) => key.substring(this.prefix.length));
648
+ }
649
+ catch {
650
+ return [];
651
+ }
652
+ }
653
+ /**
654
+ * 오래된 항목 정리 (LRU 방식)
655
+ *
656
+ * @description
657
+ * LocalStorage 용량이 부족할 때 가장 오래된 항목들을 삭제합니다.
658
+ * 타임스탬프를 기준으로 오래된 순으로 정렬하여 하위 20%를 삭제합니다.
659
+ *
660
+ * @private
661
+ * @returns {Promise<void>}
662
+ */
663
+ async clearOldItems() {
664
+ try {
665
+ const items = [];
666
+ const allKeys = Object.keys(localStorage);
667
+ const prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix));
668
+ // 타임스탬프가 있는 항목 수집
669
+ for (const key of prefixedKeys) {
670
+ try {
671
+ const data = localStorage.getItem(key);
672
+ if (data) {
673
+ const parsed = JSON.parse(data);
674
+ if (parsed && typeof parsed === 'object' && 'timestamp' in parsed) {
675
+ items.push({
676
+ key,
677
+ timestamp: parsed.timestamp || 0,
678
+ });
679
+ }
680
+ }
681
+ }
682
+ catch {
683
+ // 파싱 실패한 항목은 무시
684
+ }
685
+ }
686
+ // 오래된 순으로 정렬
687
+ items.sort((a, b) => a.timestamp - b.timestamp);
688
+ // 가장 오래된 20% 삭제 (최소 1개)
689
+ const deleteCount = Math.max(1, Math.floor(items.length * 0.2));
690
+ for (let i = 0; i < deleteCount && i < items.length; i++) {
691
+ localStorage.removeItem(items[i].key);
692
+ }
693
+ }
694
+ catch (error) {
695
+ // 전체 삭제 대신 에러 로깅만 수행
696
+ console.error('[Vircle] LocalStorage 정리 중 오류:', error);
697
+ }
698
+ }
699
+ /**
700
+ * 타임스탬프와 함께 저장 (LRU 지원)
701
+ *
702
+ * @deprecated set 메서드가 이미 타임스탬프를 포함합니다
703
+ * @param {string} key - 키
704
+ * @param {T} value - 저장할 값
705
+ * @returns {Promise<void>}
706
+ */
707
+ async setWithTimestamp(key, value) {
708
+ // 이제 set 메서드가 자동으로 타임스탬프를 저장합니다
709
+ await this.set(key, value);
710
+ }
711
+ }
712
+
713
+ /**
714
+ * 웹 성능 측정 및 추적
715
+ */
716
+ class WebPerformanceTracker {
717
+ constructor() {
718
+ this.marks = new Map();
719
+ }
720
+ /**
721
+ * 성능 추적 시작
722
+ */
723
+ startTracking() {
724
+ if (!this.isSupported()) {
725
+ return;
726
+ }
727
+ // Long Task 감지
728
+ this.observeLongTasks();
729
+ // Layout Shift 감지
730
+ this.observeLayoutShifts();
731
+ // Largest Contentful Paint 감지
732
+ this.observeLCP();
733
+ // First Input Delay 감지
734
+ this.observeFID();
735
+ }
736
+ /**
737
+ * 성능 추적 중지
738
+ */
739
+ stopTracking() {
740
+ if (this.observer) {
741
+ this.observer.disconnect();
742
+ this.observer = undefined;
743
+ }
744
+ this.marks.clear();
745
+ }
746
+ /**
747
+ * Performance API 지원 여부 확인
748
+ */
749
+ isSupported() {
750
+ return (typeof PerformanceObserver !== 'undefined' &&
751
+ typeof performance !== 'undefined' &&
752
+ typeof performance.mark === 'function');
753
+ }
754
+ /**
755
+ * Long Task 감지
756
+ */
757
+ observeLongTasks() {
758
+ if (!('PerformanceObserver' in window)) {
759
+ return;
760
+ }
761
+ try {
762
+ const observer = new PerformanceObserver((list) => {
763
+ for (const entry of list.getEntries()) {
764
+ if (entry.duration > 50) {
765
+ // 50ms 이상
766
+ console.warn('[Vircle] Long task detected:', {
767
+ duration: entry.duration,
768
+ startTime: entry.startTime,
769
+ name: entry.name,
770
+ });
771
+ }
772
+ }
773
+ });
774
+ observer.observe({ entryTypes: ['longtask'] });
775
+ }
776
+ catch {
777
+ // Long Task API를 지원하지 않는 브라우저
778
+ }
779
+ }
780
+ /**
781
+ * Layout Shift 감지
782
+ */
783
+ observeLayoutShifts() {
784
+ if (!('PerformanceObserver' in window)) {
785
+ return;
786
+ }
787
+ try {
788
+ const observer = new PerformanceObserver((list) => {
789
+ for (const entry of list.getEntries()) {
790
+ const layoutShiftEntry = entry;
791
+ if (!layoutShiftEntry.hadRecentInput) {
792
+ // CLS would be tracked here: layoutShiftEntry.value
793
+ }
794
+ }
795
+ });
796
+ observer.observe({ entryTypes: ['layout-shift'] });
797
+ }
798
+ catch {
799
+ // Layout Shift API를 지원하지 않는 브라우저
800
+ }
801
+ }
802
+ /**
803
+ * Largest Contentful Paint 감지
804
+ */
805
+ observeLCP() {
806
+ if (!('PerformanceObserver' in window)) {
807
+ return;
808
+ }
809
+ try {
810
+ const observer = new PerformanceObserver((list) => {
811
+ const entries = list.getEntries();
812
+ if (entries.length > 0) {
813
+ // LCP tracked: entries[entries.length - 1].startTime
814
+ }
815
+ });
816
+ observer.observe({ entryTypes: ['largest-contentful-paint'] });
817
+ }
818
+ catch {
819
+ // LCP API를 지원하지 않는 브라우저
820
+ }
821
+ }
822
+ /**
823
+ * First Input Delay 감지
824
+ */
825
+ observeFID() {
826
+ if (!('PerformanceObserver' in window)) {
827
+ return;
828
+ }
829
+ try {
830
+ const observer = new PerformanceObserver((list) => {
831
+ for (const entry of list.getEntries()) {
832
+ const fidEntry = entry;
833
+ if (fidEntry.processingStart && fidEntry.startTime) {
834
+ // FID tracked: fidEntry.processingStart - fidEntry.startTime
835
+ }
836
+ }
837
+ });
838
+ observer.observe({ entryTypes: ['first-input'] });
839
+ }
840
+ catch {
841
+ // FID API를 지원하지 않는 브라우저
842
+ }
843
+ }
844
+ /**
845
+ * 커스텀 성능 마크 시작
846
+ * @param name - 마크 이름
847
+ */
848
+ mark(name) {
849
+ if (!this.isSupported()) {
850
+ return;
851
+ }
852
+ performance.mark(`vircle_${name}_start`);
853
+ this.marks.set(name, performance.now());
854
+ }
855
+ /**
856
+ * 커스텀 성능 측정
857
+ * @param name - 마크 이름
858
+ * @returns 측정된 시간 (ms)
859
+ */
860
+ measure(name) {
861
+ if (!this.isSupported() || !this.marks.has(name)) {
862
+ return null;
863
+ }
864
+ const startTime = this.marks.get(name);
865
+ const duration = performance.now() - startTime;
866
+ performance.mark(`vircle_${name}_end`);
867
+ performance.measure(`vircle_${name}`, `vircle_${name}_start`, `vircle_${name}_end`);
868
+ this.marks.delete(name);
869
+ return duration;
870
+ }
871
+ /**
872
+ * 페이지 로드 메트릭 가져오기
873
+ */
874
+ getLoadMetrics() {
875
+ if (!performance || !performance.timing) {
876
+ return {};
877
+ }
878
+ const timing = performance.timing;
879
+ const metrics = {};
880
+ // DNS 조회 시간
881
+ if (timing.domainLookupEnd && timing.domainLookupStart) {
882
+ metrics.dns = timing.domainLookupEnd - timing.domainLookupStart;
883
+ }
884
+ // TCP 연결 시간
885
+ if (timing.connectEnd && timing.connectStart) {
886
+ metrics.tcp = timing.connectEnd - timing.connectStart;
887
+ }
888
+ // 요청 시간
889
+ if (timing.responseStart && timing.requestStart) {
890
+ metrics.request = timing.responseStart - timing.requestStart;
891
+ }
892
+ // 응답 시간
893
+ if (timing.responseEnd && timing.responseStart) {
894
+ metrics.response = timing.responseEnd - timing.responseStart;
895
+ }
896
+ // DOM 처리 시간
897
+ if (timing.domComplete && timing.domLoading) {
898
+ metrics.domProcessing = timing.domComplete - timing.domLoading;
899
+ }
900
+ // 페이지 로드 완료 시간
901
+ if (timing.loadEventEnd && timing.navigationStart) {
902
+ metrics.pageLoad = timing.loadEventEnd - timing.navigationStart;
903
+ }
904
+ return metrics;
905
+ }
906
+ /**
907
+ * 리소스 타이밍 데이터 가져오기
908
+ */
909
+ getResourceTimings() {
910
+ if (!performance || !performance.getEntriesByType) {
911
+ return [];
912
+ }
913
+ const resources = performance.getEntriesByType('resource');
914
+ return resources.map((resource) => ({
915
+ name: resource.name,
916
+ type: resource.initiatorType,
917
+ duration: resource.duration,
918
+ size: resource.transferSize || 0,
919
+ }));
920
+ }
921
+ }
922
+ /**
923
+ * 함수 실행 시간 측정 데코레이터
924
+ */
925
+ function measurePerformance(target, propertyKey, descriptor) {
926
+ const originalMethod = descriptor.value;
927
+ descriptor.value = async function (...args) {
928
+ const start = performance.now();
929
+ const result = await originalMethod.apply(this, args);
930
+ performance.now() - start;
931
+ return result;
932
+ };
933
+ return descriptor;
934
+ }
935
+ /**
936
+ * 디바운스 유틸리티
937
+ * @param func - 디바운스할 함수
938
+ * @param wait - 대기 시간 (ms)
939
+ * @returns 디바운스된 함수
940
+ */
941
+ function debounce(func, wait) {
942
+ let timeoutId = null;
943
+ return function debounced(...args) {
944
+ if (timeoutId) {
945
+ clearTimeout(timeoutId);
946
+ }
947
+ timeoutId = setTimeout(() => {
948
+ func(...args);
949
+ timeoutId = null;
950
+ }, wait);
951
+ };
952
+ }
953
+ /**
954
+ * 쓰로틀 유틸리티
955
+ * @param func - 쓰로틀할 함수
956
+ * @param limit - 제한 시간 (ms)
957
+ * @returns 쓰로틀된 함수
958
+ */
959
+ function throttle(func, limit) {
960
+ let inThrottle = false;
961
+ let lastArgs = null;
962
+ return function throttled(...args) {
963
+ if (!inThrottle) {
964
+ func(...args);
965
+ inThrottle = true;
966
+ setTimeout(() => {
967
+ inThrottle = false;
968
+ if (lastArgs) {
969
+ const args = lastArgs;
970
+ lastArgs = null;
971
+ throttled(...args);
972
+ }
973
+ }, limit);
974
+ }
975
+ else {
976
+ lastArgs = args;
977
+ }
978
+ };
979
+ }
980
+
981
+ /**
982
+ * Web 플랫폼용 Vircle SDK
983
+ *
984
+ * @example
985
+ * ```typescript
986
+ * const vircle = new VircleWeb({
987
+ * apiKey: 'your-api-key',
988
+ * trackPageViews: true,
989
+ * trackErrors: true
990
+ * })
991
+ *
992
+ * await vircle.initialize()
993
+ * ```
994
+ */
995
+ class VircleWeb extends VircleCore {
996
+ constructor(config, options = {}) {
997
+ // 웹 환경용 스토리지 어댑터 설정
998
+ const storageAdapter = new LocalStorageAdapter(config.storagePrefix);
999
+ const coreOptions = {
1000
+ ...options,
1001
+ storageAdapter,
1002
+ };
1003
+ super(config, coreOptions);
1004
+ this.pageViewTracked = false;
1005
+ this.webConfig = config;
1006
+ this.webOptions = {
1007
+ flushOnUnload: true,
1008
+ contextCacheTime: 300000, // 5분
1009
+ idleScheduler: new WebIdleScheduler(),
1010
+ enablePerformance: true,
1011
+ ...options,
1012
+ };
1013
+ // 웹 환경 전용 컴포넌트 초기화
1014
+ this.contextCollector = new WebContextCollector();
1015
+ if (this.webOptions.enablePerformance) {
1016
+ this.performanceTracker = new WebPerformanceTracker();
1017
+ }
1018
+ }
1019
+ /**
1020
+ * SDK 초기화 및 자동 추적 설정
1021
+ *
1022
+ * @description
1023
+ * SDK를 초기화하고 설정에 따라 자동 추적 기능을 활성화합니다.
1024
+ * 페이지뷰, 에러, 클릭, 폼 제출 등의 자동 추적을 설정할 수 있습니다.
1025
+ *
1026
+ * @example
1027
+ * ```typescript
1028
+ * const vircle = new VircleWeb({
1029
+ * apiKey: 'your-api-key',
1030
+ * trackPageViews: true,
1031
+ * trackErrors: true
1032
+ * })
1033
+ *
1034
+ * await vircle.initialize()
1035
+ * ```
1036
+ *
1037
+ * @throws {VircleInitializationError} 초기화 실패 시
1038
+ * @returns {Promise<void>}
1039
+ */
1040
+ async initialize() {
1041
+ try {
1042
+ await super.initialize();
1043
+ // 자동 추적 핸들러 설정
1044
+ this.setupAutoTracking();
1045
+ // 초기 컨텍스트 수집
1046
+ const context = await this.contextCollector.collect();
1047
+ this.setContext(context);
1048
+ // 초기 페이지뷰 추적
1049
+ if (this.webConfig.trackPageViews && !this.pageViewTracked) {
1050
+ await this.trackPageView();
1051
+ this.pageViewTracked = true;
1052
+ }
1053
+ // 성능 메트릭 수집 시작
1054
+ if (this.performanceTracker) {
1055
+ this.performanceTracker.startTracking();
1056
+ }
1057
+ }
1058
+ catch (error) {
1059
+ console.error('[Vircle] 초기화 실패:', error);
1060
+ throw error;
1061
+ }
1062
+ }
1063
+ /**
1064
+ * 페이지뷰 추적
1065
+ *
1066
+ * @description
1067
+ * 현재 페이지의 방문을 추적합니다. URL, 제목, 리퍼러 등의 정보를 자동으로 수집합니다.
1068
+ *
1069
+ * @example
1070
+ * ```typescript
1071
+ * // 기본 페이지뷰 추적
1072
+ * await vircle.trackPageView()
1073
+ *
1074
+ * // 추가 속성과 함께 추적
1075
+ * await vircle.trackPageView({
1076
+ * category: 'blog',
1077
+ * author: 'john.doe'
1078
+ * })
1079
+ * ```
1080
+ *
1081
+ * @param {Record<string, unknown>} [properties] - 추가 속성
1082
+ * @param {EventContext} [context] - 추가 컨텍스트
1083
+ * @returns {Promise<void>}
1084
+ */
1085
+ async trackPageView(properties, context) {
1086
+ const pageContext = await this.contextCollector.collectPageContext();
1087
+ return this.track('page_view', {
1088
+ ...pageContext,
1089
+ ...properties,
1090
+ }, context);
1091
+ }
1092
+ /**
1093
+ * 웹 환경에 최적화된 이벤트 추적
1094
+ * AIDEV-NOTE: 이제 코어의 TaskScheduler를 사용하여 requestIdleCallback 기반의
1095
+ * 일관된 성능 최적화 구현
1096
+ */
1097
+ async track(name, properties, context) {
1098
+ return new Promise((resolve) => {
1099
+ this.taskScheduler.enqueue(async () => {
1100
+ try {
1101
+ const webContext = await this.getCachedContext();
1102
+ const mergedContext = {
1103
+ ...webContext,
1104
+ ...context,
1105
+ };
1106
+ await super.track(name, properties, mergedContext);
1107
+ resolve();
1108
+ }
1109
+ catch (error) {
1110
+ console.warn(`[Vircle] 이벤트 추적 실패 "${name}":`, error);
1111
+ resolve(); // 에러 시에도 resolve하여 앱 크래시 방지
1112
+ }
1113
+ });
1114
+ });
1115
+ }
1116
+ /**
1117
+ * 캐시된 컨텍스트 반환 또는 새로 수집
1118
+ *
1119
+ * @description
1120
+ * 컨텍스트 정보를 캐시에서 가져오거나 새로 수집합니다.
1121
+ * 성능 최적화를 위해 캐싱을 사용하며, 중복 수집을 방지합니다.
1122
+ *
1123
+ * @private
1124
+ * @returns {Promise<EventContext>} 이벤트 컨텍스트
1125
+ *
1126
+ * AIDEV-NOTE: 컨텍스트 수집은 DOM 접근이 많아 비용이 높으므로
1127
+ * 캐싱을 통해 성능을 최적화. 중복 수집 방지를 위한 Promise 재사용 구현
1128
+ */
1129
+ async getCachedContext() {
1130
+ return this.contextCache.getOrCollect(() => this.contextCollector.collect());
1131
+ }
1132
+ /**
1133
+ * 자동 추적 설정
1134
+ *
1135
+ * @description
1136
+ * 설정에 따라 다양한 자동 추적 기능을 활성화합니다.
1137
+ * 페이지 언로드 시 플러시, 에러 추적, 클릭 추적, 폼 제출 추적, SPA 라우팅 추적 등을 설정합니다.
1138
+ *
1139
+ * @private
1140
+ */
1141
+ setupAutoTracking() {
1142
+ // 페이지 언로드 시 플러시
1143
+ if (this.webOptions.flushOnUnload) {
1144
+ this.unloadHandler = async () => {
1145
+ try {
1146
+ // 대기 중인 idle 작업들 먼저 처리
1147
+ // Flush any pending tasks
1148
+ await this.flush();
1149
+ // 이벤트 플러시
1150
+ await this.flush();
1151
+ }
1152
+ catch (error) {
1153
+ console.warn('[Vircle] 언로드 시 플러시 실패:', error);
1154
+ }
1155
+ };
1156
+ window.addEventListener('beforeunload', this.unloadHandler);
1157
+ // visibilitychange 이벤트도 처리 (모바일 브라우저 대응)
1158
+ document.addEventListener('visibilitychange', () => {
1159
+ if (document.visibilityState === 'hidden') {
1160
+ this.flush().catch((error) => {
1161
+ console.warn('[Vircle] 백그라운드 전환 시 플러시 실패:', error);
1162
+ });
1163
+ }
1164
+ });
1165
+ }
1166
+ // 에러 추적
1167
+ if (this.webConfig.trackErrors) {
1168
+ this.setupErrorTracking();
1169
+ }
1170
+ // 클릭 추적
1171
+ if (this.webConfig.trackClicks) {
1172
+ this.setupClickTracking();
1173
+ }
1174
+ // 폼 제출 추적
1175
+ if (this.webConfig.trackForms) {
1176
+ this.setupFormTracking();
1177
+ }
1178
+ // SPA 라우팅 추적
1179
+ if (this.webConfig.singlePageApp) {
1180
+ this.setupSPATracking();
1181
+ }
1182
+ }
1183
+ /**
1184
+ * 에러 추적 설정
1185
+ *
1186
+ * @description
1187
+ * JavaScript 에러와 처리되지 않은 Promise rejection을 자동으로 추적합니다.
1188
+ * 에러 메시지, 파일 위치, 스택 트레이스 등의 정보를 수집합니다.
1189
+ *
1190
+ * @private
1191
+ */
1192
+ setupErrorTracking() {
1193
+ this.errorHandler = (event) => {
1194
+ this.track('error', {
1195
+ message: event.message,
1196
+ source: event.filename,
1197
+ line: event.lineno,
1198
+ column: event.colno,
1199
+ stack: event.error?.stack,
1200
+ }).catch((error) => {
1201
+ console.warn('[Vircle] 에러 추적 실패:', error);
1202
+ });
1203
+ };
1204
+ this.unhandledRejectionHandler = (event) => {
1205
+ this.track('unhandled_rejection', {
1206
+ reason: event.reason?.toString(),
1207
+ promise: event.promise?.toString(),
1208
+ }).catch((error) => {
1209
+ console.warn('[Vircle] Promise rejection 추적 실패:', error);
1210
+ });
1211
+ };
1212
+ window.addEventListener('error', this.errorHandler);
1213
+ window.addEventListener('unhandledrejection', this.unhandledRejectionHandler);
1214
+ }
1215
+ /**
1216
+ * 클릭 추적 설정
1217
+ *
1218
+ * @description
1219
+ * 사용자의 클릭 이벤트를 자동으로 추적합니다.
1220
+ * 링크, 버튼, 입력 필드 등의 의미 있는 요소들의 클릭을 감지합니다.
1221
+ * data-vircle-ignore 속성이 있는 요소는 추적에서 제외됩니다.
1222
+ *
1223
+ * @private
1224
+ */
1225
+ setupClickTracking() {
1226
+ this.clickHandler = (event) => {
1227
+ const target = event.target;
1228
+ // 추적할 요소 필터링
1229
+ if (!this.shouldTrackElement(target)) {
1230
+ return;
1231
+ }
1232
+ const properties = this.extractElementProperties(target);
1233
+ this.track('element_clicked', properties).catch((error) => {
1234
+ console.warn('[Vircle] 클릭 추적 실패:', error);
1235
+ });
1236
+ };
1237
+ document.addEventListener('click', this.clickHandler, true);
1238
+ }
1239
+ /**
1240
+ * 폼 제출 추적 설정
1241
+ *
1242
+ * @description
1243
+ * HTML 폼의 제출 이벤트를 자동으로 추적합니다.
1244
+ * 폼 ID, 이름, action URL, 메서드 등의 정보를 수집합니다.
1245
+ *
1246
+ * @private
1247
+ */
1248
+ setupFormTracking() {
1249
+ this.submitHandler = (event) => {
1250
+ const form = event.target;
1251
+ const properties = {
1252
+ form_id: form.id,
1253
+ form_name: form.name,
1254
+ form_action: form.action,
1255
+ form_method: form.method,
1256
+ };
1257
+ this.track('form_submitted', properties).catch((error) => {
1258
+ console.warn('[Vircle] 폼 제출 추적 실패:', error);
1259
+ });
1260
+ };
1261
+ document.addEventListener('submit', this.submitHandler, true);
1262
+ }
1263
+ /**
1264
+ * SPA 라우팅 추적 설정
1265
+ *
1266
+ * @description
1267
+ * 싱글 페이지 애플리케이션(SPA)의 라우팅 변경을 감지하고 추적합니다.
1268
+ * History API의 pushState, replaceState를 래핑하고 popstate 이벤트를 수신하여
1269
+ * 클라이언트 사이드 라우팅 변경을 감지합니다.
1270
+ *
1271
+ * @private
1272
+ */
1273
+ setupSPATracking() {
1274
+ // pushState/replaceState 래핑
1275
+ const originalPushState = history.pushState;
1276
+ const originalReplaceState = history.replaceState;
1277
+ history.pushState = (...args) => {
1278
+ originalPushState.apply(history, args);
1279
+ this.handleRouteChange();
1280
+ };
1281
+ history.replaceState = (...args) => {
1282
+ originalReplaceState.apply(history, args);
1283
+ this.handleRouteChange();
1284
+ };
1285
+ // popstate 이벤트 리스너
1286
+ this.popstateHandler = () => {
1287
+ this.handleRouteChange();
1288
+ };
1289
+ window.addEventListener('popstate', this.popstateHandler);
1290
+ }
1291
+ /**
1292
+ * 라우트 변경 처리
1293
+ *
1294
+ * @description
1295
+ * SPA에서 라우트가 변경될 때 호출되는 핸들러입니다.
1296
+ * 컨텍스트 캐시를 무효화하고 새로운 페이지뷰를 추적합니다.
1297
+ *
1298
+ * @private
1299
+ */
1300
+ handleRouteChange() {
1301
+ // 캐시 무효화
1302
+ this.contextCache.invalidate();
1303
+ // 새 페이지뷰 추적
1304
+ if (this.webConfig.trackPageViews) {
1305
+ this.trackPageView().catch((error) => {
1306
+ console.warn('[Vircle] 라우트 변경 추적 실패:', error);
1307
+ });
1308
+ }
1309
+ }
1310
+ /**
1311
+ * 요소가 추적 대상인지 확인
1312
+ *
1313
+ * @description
1314
+ * HTML 요소가 자동 추적 대상인지 판단합니다.
1315
+ * data-vircle-ignore 속성이 있거나 부모 요소에 해당 속성이 있으면 false를 반환합니다.
1316
+ *
1317
+ * @param {HTMLElement} element - 확인할 HTML 요소
1318
+ * @returns {boolean} 추적 대상 여부
1319
+ * @private
1320
+ */
1321
+ shouldTrackElement(element) {
1322
+ // data-vircle-ignore 속성이 있으면 무시
1323
+ if (element.hasAttribute('data-vircle-ignore')) {
1324
+ return false;
1325
+ }
1326
+ // 부모 요소에도 ignore 속성이 있는지 확인
1327
+ let parent = element.parentElement;
1328
+ while (parent) {
1329
+ if (parent.hasAttribute('data-vircle-ignore')) {
1330
+ return false;
1331
+ }
1332
+ parent = parent.parentElement;
1333
+ }
1334
+ // 의미있는 요소들만 추적
1335
+ const trackableTags = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
1336
+ return trackableTags.includes(element.tagName);
1337
+ }
1338
+ /**
1339
+ * 요소에서 속성 추출
1340
+ *
1341
+ * @description
1342
+ * HTML 요소에서 추적에 필요한 속성을 추출합니다.
1343
+ * 태그 이름, ID, 클래스, href, 텍스트 내용 등을 수집합니다.
1344
+ *
1345
+ * @param {HTMLElement} element - 속성을 추출할 HTML 요소
1346
+ * @returns {Record<string, unknown>} 추출된 속성 객체
1347
+ * @private
1348
+ */
1349
+ extractElementProperties(element) {
1350
+ const properties = {
1351
+ tag_name: element.tagName.toLowerCase(),
1352
+ element_id: element.id || undefined,
1353
+ element_class: element.className || undefined,
1354
+ element_href: element.href || undefined,
1355
+ element_text: element.textContent?.slice(0, 100) || undefined,
1356
+ };
1357
+ // 추가 속성 수집은 idle 시간에 처리 (성능 최적화)
1358
+ this.taskScheduler.enqueue(() => {
1359
+ // data-* 속성 수집
1360
+ const dataAttributes = {};
1361
+ Array.from(element.attributes).forEach((attr) => {
1362
+ if (attr.name.startsWith('data-') && !attr.name.startsWith('data-vircle-')) {
1363
+ dataAttributes[attr.name] = attr.value;
1364
+ }
1365
+ });
1366
+ if (Object.keys(dataAttributes).length > 0) {
1367
+ // 비동기로 추가 속성을 포함한 이벤트 업데이트
1368
+ this.track('element_data_collected', {
1369
+ ...properties,
1370
+ data_attributes: dataAttributes,
1371
+ }).catch((error) => {
1372
+ console.debug('[Vircle] Data attributes collection failed:', error);
1373
+ });
1374
+ }
1375
+ });
1376
+ return properties;
1377
+ }
1378
+ /**
1379
+ * SDK 리소스 정리
1380
+ *
1381
+ * @description
1382
+ * SDK가 사용하는 모든 리소스를 정리합니다.
1383
+ * 이벤트 리스너 제거, 성능 추적 중지, 대기 중인 이벤트 전송 등을 수행합니다.
1384
+ *
1385
+ * @example
1386
+ * ```typescript
1387
+ * // 앱 종료 시 정리
1388
+ * window.addEventListener('beforeunload', async () => {
1389
+ * await vircle.cleanup()
1390
+ * })
1391
+ * ```
1392
+ *
1393
+ * @throws {Error} 정리 중 오류 발생 시
1394
+ * @returns {Promise<void>}
1395
+ */
1396
+ async cleanup() {
1397
+ try {
1398
+ // 이벤트 리스너 제거
1399
+ if (this.unloadHandler) {
1400
+ window.removeEventListener('beforeunload', this.unloadHandler);
1401
+ }
1402
+ if (this.errorHandler) {
1403
+ window.removeEventListener('error', this.errorHandler);
1404
+ }
1405
+ if (this.unhandledRejectionHandler) {
1406
+ window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler);
1407
+ }
1408
+ if (this.clickHandler) {
1409
+ document.removeEventListener('click', this.clickHandler, true);
1410
+ }
1411
+ if (this.submitHandler) {
1412
+ document.removeEventListener('submit', this.submitHandler, true);
1413
+ }
1414
+ if (this.popstateHandler) {
1415
+ window.removeEventListener('popstate', this.popstateHandler);
1416
+ }
1417
+ // 성능 추적 정지
1418
+ if (this.performanceTracker) {
1419
+ this.performanceTracker.stopTracking();
1420
+ }
1421
+ // Idle 작업 정리
1422
+ // Flush pending tasks
1423
+ await this.flush();
1424
+ // 부모 cleanup 호출
1425
+ await super.cleanup();
1426
+ }
1427
+ catch (error) {
1428
+ console.error('[Vircle] 정리 중 오류:', error);
1429
+ throw error;
1430
+ }
1431
+ }
1432
+ /**
1433
+ * 현재 페이지 URL 반환
1434
+ *
1435
+ * @description
1436
+ * 현재 브라우저의 전체 URL을 반환합니다.
1437
+ *
1438
+ * @example
1439
+ * ```typescript
1440
+ * const url = vircle.getCurrentUrl()
1441
+ * console.log(url) // https://example.com/path?query=value
1442
+ * ```
1443
+ *
1444
+ * @returns {string} 현재 페이지의 전체 URL
1445
+ */
1446
+ getCurrentUrl() {
1447
+ return window.location.href;
1448
+ }
1449
+ /**
1450
+ * 네트워크 연결 상태 확인
1451
+ *
1452
+ * @description
1453
+ * 브라우저의 온라인/오프라인 상태를 확인합니다.
1454
+ * Navigator.onLine API를 사용하여 네트워크 연결 상태를 반환합니다.
1455
+ *
1456
+ * @example
1457
+ * ```typescript
1458
+ * if (vircle.isOnline()) {
1459
+ * // 온라인 상태에서만 실행할 코드
1460
+ * await vircle.flush()
1461
+ * }
1462
+ * ```
1463
+ *
1464
+ * @returns {boolean} 온라인 상태 여부
1465
+ */
1466
+ isOnline() {
1467
+ return navigator.onLine;
1468
+ }
1469
+ }
1470
+
1471
+ export { LocalStorageAdapter, VircleBrowserCompatibilityError, VircleConfigError, VircleInitializationError, VircleStorageError, VircleWeb, VircleWebError, WebContextCollector, WebPerformanceTracker, debounce, VircleWeb as default, measurePerformance, throttle };
1472
+ //# sourceMappingURL=index.esm.js.map