@vircle/sdk-web 0.4.1 → 0.6.1

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 CHANGED
@@ -633,6 +633,10 @@ import type { VircleWebConfig, VircleWebOptions } from '@vircle/sdk-web';
633
633
  // 컨텍스트 수집기
634
634
  import { WebContextCollector } from '@vircle/sdk-web';
635
635
 
636
+ // F-4b Link Decoration — outbound link에 ?_vuid= 자동 부착 (프로그래밍 API)
637
+ import { LinkDecorator } from '@vircle/sdk-web';
638
+ import type { LinkDecoratorOptions } from '@vircle/sdk-web';
639
+
636
640
  // 스토리지
637
641
  import { LocalStorageAdapter, IndexedDBAdapter, StorageFactory } from '@vircle/sdk-web';
638
642
  import type { StorageType } from '@vircle/sdk-web'; // 'auto' | 'localStorage' | 'indexedDB'
@@ -953,6 +957,37 @@ await vircle.identify<CustomUserTraits>('user-123', {
953
957
  });
954
958
  ```
955
959
 
960
+ ## Identity Graph 강화
961
+
962
+ Meta CAPI EMQ 7+ 도달을 위한 호스트 통합(PII 해시, consent, 크로스 도메인 link decoration, Meta Pixel 활성화 등):
963
+
964
+ - [호스트 통합 가이드](./docs/host-integration-guide.md) — `vircle.identify()` PII 전달, `setConsent()`, `linkDecoration` 설정
965
+ - [Cafe24 PII 수집 가이드](../plugin-cafe24/docs/cafe24-pii-collection.md) — Cafe24 자사몰 환경 한정
966
+
967
+ 핵심 설정 예시:
968
+
969
+ ```typescript
970
+ new VircleWeb({
971
+ apiKey: 'vk_prod_...',
972
+ storageType: 'localStorage',
973
+ accountSwitchDetectionEnabled: true,
974
+ defaultConsent: { analytics: true, marketing: false, functional: true },
975
+ linkDecoration: {
976
+ enabled: true,
977
+ allowedDomains: ['.vircle.co.kr', 'shop.example.com'],
978
+ },
979
+ });
980
+
981
+ // 호스트 동의관리 응답 후
982
+ vircle.setConsent({ analytics: true, marketing: true, functional: true });
983
+
984
+ // PII는 SDK가 정규화 + SHA-256 해시 → em/ph array로 전송 (평문 키는 strip)
985
+ await vircle.identify(userId, {
986
+ email: 'user@example.com',
987
+ phone: '+82 10-1234-5678',
988
+ });
989
+ ```
990
+
956
991
  ## 라이선스
957
992
 
958
993
  MIT
package/dist/index.d.ts CHANGED
@@ -448,6 +448,29 @@ interface VircleWebConfig extends VircleConfig {
448
448
  * 지정된 시간 동안 비활성 상태이면 새 세션을 시작합니다.
449
449
  */
450
450
  sessionTimeout?: number;
451
+ /**
452
+ * F-4b 크로스 도메인 Link Decoration — outbound link에 `?_vuid=`를 click 시점에 자동 부착.
453
+ * 같은 partner의 여러 도메인 간(예: 라운지 ↔ Cafe24 자사몰) vuid 전파 자동화.
454
+ *
455
+ * 동작:
456
+ * - 페이지 로드 시 LinkDecorator가 capture-phase event delegation 등록 (mousedown/click/auxclick)
457
+ * - 사용자가 a[href] 클릭 시 link href가 `allowedDomains` 매치되면 `?_vuid=<anonymousId>` 부착
458
+ * - 정적 URL을 미리 변경하지 않음 — PR 미리보기·OG 태그 등에 vuid 노출 회피
459
+ * - 컨텍스트 메뉴("Copy link", "Open in new tab")도 mousedown 시점 갱신으로 커버
460
+ * - `allowedDomains` 매치되지 않은 외부 사이트에는 부착 안 함 (vuid 누출 방지)
461
+ */
462
+ linkDecoration?: {
463
+ /** 기본 false. true로 명시해야 활성화 */
464
+ enabled?: boolean;
465
+ /**
466
+ * vuid를 부착할 도메인 화이트리스트. hostname suffix 매칭.
467
+ * 예: ['shop.vircle.co.kr', 'lounge.vircle.co.kr'] → 정확 매치
468
+ * ['.vircle.co.kr'] → 서브도메인 포함 매치
469
+ */
470
+ allowedDomains?: string[];
471
+ /** URL 파라미터 이름. 기본 `_vuid` */
472
+ paramName?: string;
473
+ };
451
474
  }
452
475
  interface VircleWebOptions extends Partial<VircleCoreOptions> {
453
476
  /**
@@ -489,6 +512,7 @@ declare class VircleWeb extends VircleCore {
489
512
  private originalReplaceState?;
490
513
  private lastActivityTime;
491
514
  private sessionTimeout;
515
+ private linkDecorator?;
492
516
  constructor(config: VircleWebConfig, options?: VircleWebOptions);
493
517
  /**
494
518
  * SDK 초기화 및 자동 추적 설정
@@ -577,6 +601,11 @@ declare class VircleWeb extends VircleCore {
577
601
  * @private
578
602
  */
579
603
  private setupAutoTracking;
604
+ /**
605
+ * F-4b Link Decoration 활성화 — outbound link click 시 ?_vuid= 자동 부착.
606
+ * @private
607
+ */
608
+ private setupLinkDecoration;
580
609
  /**
581
610
  * 에러 추적 설정
582
611
  *
@@ -727,7 +756,17 @@ declare class WebContextCollector {
727
756
  */
728
757
  private static readonly CAMPAIGN_PARAMS_MAP;
729
758
  private static readonly STORAGE_PREFIX;
759
+ /** localStorage 영구 백업 키 (PG 리다이렉트 등으로 sessionStorage 유실 시 복원용) */
760
+ private static readonly CAMPAIGN_PERSIST_KEY;
761
+ /** localStorage 캠페인 데이터 만료 기간: 7일 (광고 어트리뷰션 윈도우) */
762
+ private static readonly CAMPAIGN_PERSIST_TTL_MS;
730
763
  collect(): Promise<EventContext>;
764
+ /**
765
+ * Meta `_fbp`/`_fbc` 쿠키 read-only 수집. 호스트가 Meta Pixel을 설치했거나
766
+ * plugin-cafe24의 MetaPixelLoader가 쿠키를 발급해 둔 경우 read한다.
767
+ * 둘 다 부재 시 undefined 반환 (EventContext.meta omit).
768
+ */
769
+ private collectMetaContext;
731
770
  /**
732
771
  * 디바이스 정보 수집
733
772
  */
@@ -765,10 +804,19 @@ declare class WebContextCollector {
765
804
  */
766
805
  private getBrowserVersion;
767
806
  /**
768
- * 캠페인 파라미터 수집 (UTM 5 + 광고 클릭 ID 4)
807
+ * 캠페인 파라미터 수집 (UTM 5 + 광고 클릭 ID 5 + 네이버 검색광고 8)
769
808
  * sessionStorage를 사용하여 MPA 페이지 전환 간에도 유지
770
809
  */
771
810
  private collectCampaignContext;
811
+ /**
812
+ * 캠페인 파라미터를 localStorage에 타임스탬프와 함께 백업.
813
+ * PG 리다이렉트로 새 탭이 열리거나 sessionStorage가 초기화되는 경우 복원용.
814
+ */
815
+ private persistCampaignToLocalStorage;
816
+ /**
817
+ * localStorage에서 캠페인 파라미터를 복원. 7일 초과 시 만료 처리.
818
+ */
819
+ private restoreCampaignFromLocalStorage;
772
820
  /**
773
821
  * 앱 이름 가져오기 (meta 태그에서)
774
822
  */
@@ -783,6 +831,68 @@ declare class WebContextCollector {
783
831
  private getEnvironment;
784
832
  }
785
833
 
834
+ /**
835
+ * @module LinkDecorator
836
+ *
837
+ * F-4b 크로스 도메인 Link Decoration — outbound link에 `?_vuid=` click 시점 자동 부착.
838
+ *
839
+ * 같은 partner의 여러 도메인 간(예: 라운지 ↔ Cafe24 자사몰) vuid 전파 자동화.
840
+ * GA4 `linker` plugin과 동일 패턴.
841
+ *
842
+ * 설계 결정:
843
+ * - **정적 URL 미변경**: 페이지 로드 시 `<a href>` 속성을 직접 수정하지 않는다. PR 미리보기·OG 태그 등
844
+ * 에서 vuid 노출 회피.
845
+ * - **click 시점 동적 부착**: capturing-phase click 리스너가 `a[href]` 매치 시 즉시 href 갱신
846
+ * (delegate 방식 — 동적으로 추가된 link도 자동 커버).
847
+ * - **MutationObserver 불필요**: capture-phase delegate가 동적 콘텐츠도 처리하므로 별도 observer 불요
848
+ * (성능·복잡도 절감).
849
+ * - **allowedDomains 화이트리스트**: 매치 안 된 외부 사이트에는 부착 안 함 (vuid 누출 방지).
850
+ *
851
+ * Limitations:
852
+ * - JavaScript 비활성 환경에선 동작 안 함 (정적 URL 미변경 트레이드오프)
853
+ * - middle-click/wheel-click도 처리하기 위해 'auxclick' 이벤트도 리슨
854
+ * - `target="_blank"` 새 창 link도 동일 처리
855
+ */
856
+ interface LinkDecoratorOptions {
857
+ /** vuid를 부착할 도메인 화이트리스트. hostname suffix 매칭 */
858
+ allowedDomains: string[];
859
+ /** URL 파라미터 이름 (기본 `_vuid`) */
860
+ paramName?: string;
861
+ /** 현재 anonymousId를 반환하는 콜백 — click 시점에 호출 */
862
+ getAnonymousId: () => string | undefined;
863
+ }
864
+ declare class LinkDecorator {
865
+ private allowedDomains;
866
+ private paramName;
867
+ private getAnonymousId;
868
+ private clickHandler?;
869
+ constructor(options: LinkDecoratorOptions);
870
+ /**
871
+ * mousedown/click/auxclick 이벤트 capture-phase delegate 등록. 동적 추가 link도 자동 커버.
872
+ * SSR/non-DOM 환경(typeof document === 'undefined')에서는 no-op.
873
+ *
874
+ * 이벤트별 역할:
875
+ * - `mousedown`: 우클릭 후 "Copy link address" / "Open in new tab" 컨텍스트 메뉴 케이스 커버
876
+ * (이 흐름에서는 click이 발생하지 않으므로 mousedown 시점에 href 갱신)
877
+ * - `click`: 키보드 네비게이션(focus + Enter) 등 mousedown 미발생 케이스 백업
878
+ * - `auxclick`: middle-click (휠 클릭으로 새 탭 열기)
879
+ */
880
+ start(): void;
881
+ stop(): void;
882
+ /**
883
+ * 외부 호출용 — 임의 URL에 vuid 파라미터 부착. 화이트리스트 매치되지 않으면 원본 URL 반환.
884
+ * 호스트가 동적으로 만든 URL 등에 사용 가능.
885
+ */
886
+ decorate(href: string): string;
887
+ private handleClick;
888
+ /**
889
+ * hostname 화이트리스트 매칭.
890
+ * - `.example.com` → suffix match (서브도메인 포함)
891
+ * - `example.com` → exact match
892
+ */
893
+ private isAllowedDomain;
894
+ }
895
+
786
896
  /**
787
897
  * Web Crypto API 기반 암호화 어댑터
788
898
  *
@@ -819,6 +929,11 @@ declare class WebExtendedCryptoAdapter implements ExtendedCryptoAdapter {
819
929
  * 2. RSA-OAEP로 AES 키 암호화
820
930
  */
821
931
  encryptPayload(data: Record<string, any>, publicKeyBase64: string): Promise<EncryptedPayload>;
932
+ /**
933
+ * SHA-256 해시 — Meta CAPI em/ph 등 PII 매칭 키 생성용.
934
+ * Web Crypto API의 crypto.subtle.digest 사용.
935
+ */
936
+ sha256(input: string): Promise<string>;
822
937
  /**
823
938
  * Web Crypto API 사용 가능 여부
824
939
  */
@@ -941,5 +1056,5 @@ declare class VircleBrowserCompatibilityError extends VircleWebError {
941
1056
  constructor(message: string, details?: Record<string, any>);
942
1057
  }
943
1058
 
944
- export { IndexedDBAdapter, LocalStorageAdapter, StorageFactory, VircleBrowserCompatibilityError, VircleConfigError, VircleInitializationError, VircleStorageError, VircleWeb, VircleWebError, WebContextCollector, WebExtendedCryptoAdapter, VircleWeb as default };
945
- export type { StorageType, VircleWebConfig, VircleWebOptions };
1059
+ export { IndexedDBAdapter, LinkDecorator, LocalStorageAdapter, StorageFactory, VircleBrowserCompatibilityError, VircleConfigError, VircleInitializationError, VircleStorageError, VircleWeb, VircleWebError, WebContextCollector, WebExtendedCryptoAdapter, VircleWeb as default };
1060
+ export type { LinkDecoratorOptions, StorageType, VircleWebConfig, VircleWebOptions };
package/dist/index.esm.js CHANGED
@@ -1,5 +1,65 @@
1
1
  import { VircleCore, WebIdleScheduler } from '@vircle/sdk-core-ts';
2
2
 
3
+ /**
4
+ * @module MetaCookieCollector
5
+ *
6
+ * Meta(Facebook) `_fbp`/`_fbc` 쿠키 **read-only** 수집기.
7
+ *
8
+ * 책임 범위:
9
+ * - document.cookie에서 `_fbp`, `_fbc` 값 읽어 EventContext.meta에 첨부.
10
+ * - **자체 생성 안 함**. fbclid → `_fbc` 변환, `_fbp` 생성 등 쓰기 작업은 plugin-cafe24의
11
+ * MetaPixelLoader가 opt-in 3-조건 AND(enable_pixel + vircle_pixel_id + consent.marketing)
12
+ * 충족 시에만 수행한다 — 책임 분리.
13
+ *
14
+ * 모든 web 환경(Cafe24 외 SmartStore 등 향후 partner 포함)에서 공통으로 cookie를 read한다.
15
+ */
16
+ const FBP_KEY = '_fbp';
17
+ const FBC_KEY = '_fbc';
18
+ /**
19
+ * document.cookie를 파싱하여 `_fbp`, `_fbc` 값을 반환.
20
+ * 호스트 사이트가 Meta Pixel을 설치한 환경에서는 Pixel이 이미 cookie를 발급해 둔다.
21
+ *
22
+ * @returns 발견된 값만 포함하는 객체. 둘 다 부재 시 빈 객체.
23
+ */
24
+ function readMetaCookies() {
25
+ if (typeof document === 'undefined' || !document.cookie) {
26
+ return {};
27
+ }
28
+ const cookies = parseCookieString(document.cookie);
29
+ const result = {};
30
+ if (cookies[FBP_KEY])
31
+ result.fbp = cookies[FBP_KEY];
32
+ if (cookies[FBC_KEY])
33
+ result.fbc = cookies[FBC_KEY];
34
+ return result;
35
+ }
36
+ /**
37
+ * cookie 문자열을 key-value 객체로 파싱.
38
+ * "a=1; b=2; c=3" → { a: '1', b: '2', c: '3' }
39
+ */
40
+ function parseCookieString(cookieStr) {
41
+ const result = {};
42
+ const pairs = cookieStr.split(';');
43
+ for (const pair of pairs) {
44
+ const eqIdx = pair.indexOf('=');
45
+ if (eqIdx <= 0)
46
+ continue;
47
+ const name = pair.slice(0, eqIdx).trim();
48
+ const value = pair.slice(eqIdx + 1).trim();
49
+ if (!name || !value)
50
+ continue;
51
+ // 잘못된 URI 인코딩(예: '%' 단독)에 decodeURIComponent가 URIError를 던질 수 있으므로
52
+ // 원본 raw 값 fallback. 다른 cookie 파싱이 중단되지 않게 한다.
53
+ try {
54
+ result[name] = decodeURIComponent(value);
55
+ }
56
+ catch {
57
+ result[name] = value;
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+
3
63
  /**
4
64
  * 웹 브라우저 환경 정보 수집기
5
65
  */
@@ -20,8 +80,25 @@ class WebContextCollector {
20
80
  if (campaign) {
21
81
  result.campaign = campaign;
22
82
  }
83
+ // Meta(Facebook) Pixel cookie read-only. fbclid → _fbc 변환 및 _fbp 자체 생성은
84
+ // plugin-cafe24의 MetaPixelLoader가 opt-in 3-조건 AND 충족 시에만 수행한다.
85
+ const meta = this.collectMetaContext();
86
+ if (meta) {
87
+ result.meta = meta;
88
+ }
23
89
  return result;
24
90
  }
91
+ /**
92
+ * Meta `_fbp`/`_fbc` 쿠키 read-only 수집. 호스트가 Meta Pixel을 설치했거나
93
+ * plugin-cafe24의 MetaPixelLoader가 쿠키를 발급해 둔 경우 read한다.
94
+ * 둘 다 부재 시 undefined 반환 (EventContext.meta omit).
95
+ */
96
+ collectMetaContext() {
97
+ const cookies = readMetaCookies();
98
+ if (!cookies.fbp && !cookies.fbc)
99
+ return undefined;
100
+ return cookies;
101
+ }
25
102
  /**
26
103
  * 디바이스 정보 수집
27
104
  */
@@ -120,10 +197,10 @@ class WebContextCollector {
120
197
  return 'Windows';
121
198
  if (/macintosh|mac os x/i.test(ua))
122
199
  return 'macOS';
123
- if (/linux/i.test(ua))
124
- return 'Linux';
125
200
  if (/android/i.test(ua))
126
201
  return 'Android';
202
+ if (/linux/i.test(ua))
203
+ return 'Linux';
127
204
  if (/iphone|ipad|ipod/i.test(ua))
128
205
  return 'iOS';
129
206
  if (/cros/i.test(ua))
@@ -190,7 +267,7 @@ class WebContextCollector {
190
267
  return version ? version[1] : undefined;
191
268
  }
192
269
  /**
193
- * 캠페인 파라미터 수집 (UTM 5 + 광고 클릭 ID 4)
270
+ * 캠페인 파라미터 수집 (UTM 5 + 광고 클릭 ID 5 + 네이버 검색광고 8)
194
271
  * sessionStorage를 사용하여 MPA 페이지 전환 간에도 유지
195
272
  */
196
273
  collectCampaignContext() {
@@ -206,8 +283,9 @@ class WebContextCollector {
206
283
  break;
207
284
  }
208
285
  }
209
- // 새 캠페인이면 기존 sessionStorage 클리어 후 새로 저장
286
+ // 새 캠페인이면 기존 sessionStorage 클리어 후 새로 저장 + localStorage 백업
210
287
  if (hasUrlCampaign) {
288
+ const persistParams = {};
211
289
  for (const key of allKeys) {
212
290
  try {
213
291
  sessionStorage.removeItem(WebContextCollector.STORAGE_PREFIX + key);
@@ -221,8 +299,11 @@ class WebContextCollector {
221
299
  sessionStorage.setItem(WebContextCollector.STORAGE_PREFIX + key, value);
222
300
  }
223
301
  catch { }
302
+ persistParams[key] = value;
224
303
  }
225
304
  }
305
+ // localStorage에 백업 (PG 리다이렉트 등 sessionStorage 유실 대비)
306
+ this.persistCampaignToLocalStorage(persistParams);
226
307
  }
227
308
  // sessionStorage에서 복원하여 CampaignContext 구성
228
309
  const campaign = {};
@@ -237,8 +318,58 @@ class WebContextCollector {
237
318
  }
238
319
  catch { }
239
320
  }
321
+ // sessionStorage에 캠페인이 없으면 localStorage 폴백 (도메인 복귀, 탭 전환 등)
322
+ if (!hasParams) {
323
+ const restored = this.restoreCampaignFromLocalStorage();
324
+ if (restored) {
325
+ for (const [paramKey, contextKey] of Object.entries(WebContextCollector.CAMPAIGN_PARAMS_MAP)) {
326
+ const value = restored[paramKey];
327
+ if (value) {
328
+ campaign[contextKey] = value;
329
+ hasParams = true;
330
+ // sessionStorage에도 복원하여 후속 페이지에서 사용
331
+ try {
332
+ sessionStorage.setItem(WebContextCollector.STORAGE_PREFIX + paramKey, value);
333
+ }
334
+ catch { }
335
+ }
336
+ }
337
+ }
338
+ }
240
339
  return hasParams ? campaign : undefined;
241
340
  }
341
+ /**
342
+ * 캠페인 파라미터를 localStorage에 타임스탬프와 함께 백업.
343
+ * PG 리다이렉트로 새 탭이 열리거나 sessionStorage가 초기화되는 경우 복원용.
344
+ */
345
+ persistCampaignToLocalStorage(params) {
346
+ try {
347
+ localStorage.setItem(WebContextCollector.CAMPAIGN_PERSIST_KEY, JSON.stringify({ params, timestamp: Date.now() }));
348
+ }
349
+ catch {
350
+ // localStorage 접근 불가 시 무시 (private browsing 등)
351
+ }
352
+ }
353
+ /**
354
+ * localStorage에서 캠페인 파라미터를 복원. 7일 초과 시 만료 처리.
355
+ */
356
+ restoreCampaignFromLocalStorage() {
357
+ try {
358
+ const raw = localStorage.getItem(WebContextCollector.CAMPAIGN_PERSIST_KEY);
359
+ if (!raw)
360
+ return null;
361
+ const data = JSON.parse(raw);
362
+ if (Date.now() - data.timestamp > WebContextCollector.CAMPAIGN_PERSIST_TTL_MS) {
363
+ // 만료된 데이터 삭제
364
+ localStorage.removeItem(WebContextCollector.CAMPAIGN_PERSIST_KEY);
365
+ return null;
366
+ }
367
+ return data.params;
368
+ }
369
+ catch {
370
+ return null;
371
+ }
372
+ }
242
373
  /**
243
374
  * 앱 이름 가져오기 (meta 태그에서)
244
375
  */
@@ -287,8 +418,22 @@ WebContextCollector.CAMPAIGN_PARAMS_MAP = {
287
418
  'fbclid': 'fbclid',
288
419
  'msclkid': 'msclkid',
289
420
  'ttclid': 'ttclid',
421
+ 'kclid': 'kclid',
422
+ // 네이버 검색광고 자동추적 파라미터
423
+ 'n_media': 'n_media',
424
+ 'n_query': 'n_query',
425
+ 'n_ad': 'n_ad',
426
+ 'n_ad_group': 'n_ad_group',
427
+ 'n_keyword': 'n_keyword',
428
+ 'n_keyword_id': 'n_keyword_id',
429
+ 'n_rank': 'n_rank',
430
+ 'n_campaign_type': 'n_campaign_type',
290
431
  };
291
432
  WebContextCollector.STORAGE_PREFIX = '__vircle_campaign_';
433
+ /** localStorage 영구 백업 키 (PG 리다이렉트 등으로 sessionStorage 유실 시 복원용) */
434
+ WebContextCollector.CAMPAIGN_PERSIST_KEY = '__vircle_campaign_persist';
435
+ /** localStorage 캠페인 데이터 만료 기간: 7일 (광고 어트리뷰션 윈도우) */
436
+ WebContextCollector.CAMPAIGN_PERSIST_TTL_MS = 7 * 24 * 60 * 60 * 1000;
292
437
 
293
438
  /**
294
439
  * Vircle Web SDK 전용 에러 클래스
@@ -1501,6 +1646,18 @@ class WebExtendedCryptoAdapter {
1501
1646
  throw new Error(`[WebExtendedCryptoAdapter] Encryption failed at step '${step}': ${errorMessage}`);
1502
1647
  }
1503
1648
  }
1649
+ /**
1650
+ * SHA-256 해시 — Meta CAPI em/ph 등 PII 매칭 키 생성용.
1651
+ * Web Crypto API의 crypto.subtle.digest 사용.
1652
+ */
1653
+ async sha256(input) {
1654
+ if (!this.subtle) {
1655
+ throw new Error('Web Crypto API is required for SHA-256 hashing');
1656
+ }
1657
+ const encoder = new TextEncoder();
1658
+ const digest = await this.subtle.digest('SHA-256', encoder.encode(input));
1659
+ return this.arrayBufferToHex(digest);
1660
+ }
1504
1661
  /**
1505
1662
  * Web Crypto API 사용 가능 여부
1506
1663
  */
@@ -1564,6 +1721,135 @@ class WebExtendedCryptoAdapter {
1564
1721
  }
1565
1722
  }
1566
1723
 
1724
+ /**
1725
+ * @module LinkDecorator
1726
+ *
1727
+ * F-4b 크로스 도메인 Link Decoration — outbound link에 `?_vuid=` click 시점 자동 부착.
1728
+ *
1729
+ * 같은 partner의 여러 도메인 간(예: 라운지 ↔ Cafe24 자사몰) vuid 전파 자동화.
1730
+ * GA4 `linker` plugin과 동일 패턴.
1731
+ *
1732
+ * 설계 결정:
1733
+ * - **정적 URL 미변경**: 페이지 로드 시 `<a href>` 속성을 직접 수정하지 않는다. PR 미리보기·OG 태그 등
1734
+ * 에서 vuid 노출 회피.
1735
+ * - **click 시점 동적 부착**: capturing-phase click 리스너가 `a[href]` 매치 시 즉시 href 갱신
1736
+ * (delegate 방식 — 동적으로 추가된 link도 자동 커버).
1737
+ * - **MutationObserver 불필요**: capture-phase delegate가 동적 콘텐츠도 처리하므로 별도 observer 불요
1738
+ * (성능·복잡도 절감).
1739
+ * - **allowedDomains 화이트리스트**: 매치 안 된 외부 사이트에는 부착 안 함 (vuid 누출 방지).
1740
+ *
1741
+ * Limitations:
1742
+ * - JavaScript 비활성 환경에선 동작 안 함 (정적 URL 미변경 트레이드오프)
1743
+ * - middle-click/wheel-click도 처리하기 위해 'auxclick' 이벤트도 리슨
1744
+ * - `target="_blank"` 새 창 link도 동일 처리
1745
+ */
1746
+ const DEFAULT_PARAM_NAME = '_vuid';
1747
+ class LinkDecorator {
1748
+ constructor(options) {
1749
+ this.allowedDomains = (options.allowedDomains ?? []).map((d) => d.toLowerCase());
1750
+ this.paramName = options.paramName ?? DEFAULT_PARAM_NAME;
1751
+ this.getAnonymousId = options.getAnonymousId;
1752
+ }
1753
+ /**
1754
+ * mousedown/click/auxclick 이벤트 capture-phase delegate 등록. 동적 추가 link도 자동 커버.
1755
+ * SSR/non-DOM 환경(typeof document === 'undefined')에서는 no-op.
1756
+ *
1757
+ * 이벤트별 역할:
1758
+ * - `mousedown`: 우클릭 후 "Copy link address" / "Open in new tab" 컨텍스트 메뉴 케이스 커버
1759
+ * (이 흐름에서는 click이 발생하지 않으므로 mousedown 시점에 href 갱신)
1760
+ * - `click`: 키보드 네비게이션(focus + Enter) 등 mousedown 미발생 케이스 백업
1761
+ * - `auxclick`: middle-click (휠 클릭으로 새 탭 열기)
1762
+ */
1763
+ start() {
1764
+ if (typeof document === 'undefined')
1765
+ return;
1766
+ if (this.allowedDomains.length === 0)
1767
+ return;
1768
+ if (this.clickHandler)
1769
+ return; // 이미 시작됨
1770
+ this.clickHandler = this.handleClick.bind(this);
1771
+ // capture phase로 등록하여 다른 핸들러가 preventDefault 호출하기 전에 href 갱신.
1772
+ // handleClick은 멱등이라 mousedown + click 둘 다 발화돼도 안전.
1773
+ document.addEventListener('mousedown', this.clickHandler, { capture: true });
1774
+ document.addEventListener('click', this.clickHandler, { capture: true });
1775
+ document.addEventListener('auxclick', this.clickHandler, { capture: true });
1776
+ }
1777
+ stop() {
1778
+ if (typeof document === 'undefined' || !this.clickHandler)
1779
+ return;
1780
+ document.removeEventListener('mousedown', this.clickHandler, { capture: true });
1781
+ document.removeEventListener('click', this.clickHandler, { capture: true });
1782
+ document.removeEventListener('auxclick', this.clickHandler, { capture: true });
1783
+ this.clickHandler = undefined;
1784
+ }
1785
+ /**
1786
+ * 외부 호출용 — 임의 URL에 vuid 파라미터 부착. 화이트리스트 매치되지 않으면 원본 URL 반환.
1787
+ * 호스트가 동적으로 만든 URL 등에 사용 가능.
1788
+ */
1789
+ decorate(href) {
1790
+ const vuid = this.getAnonymousId();
1791
+ if (!vuid)
1792
+ return href;
1793
+ let url;
1794
+ try {
1795
+ url = new URL(href, typeof location !== 'undefined' ? location.href : 'http://localhost');
1796
+ }
1797
+ catch {
1798
+ return href; // mailto:, javascript:, tel: 등 invalid URL
1799
+ }
1800
+ // http/https 외 스킴(javascript:, data:, blob: 등)은 navigation link가 아니므로 skip
1801
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
1802
+ return href;
1803
+ if (!this.isAllowedDomain(url.hostname))
1804
+ return href;
1805
+ // same-origin link는 동일 SDK 인스턴스에서 동일 vuid 사용하므로 부착 불필요 — URL noise 회피
1806
+ if (typeof location !== 'undefined' && url.origin === location.origin)
1807
+ return href;
1808
+ if (url.searchParams.get(this.paramName))
1809
+ return href; // 이미 부착됨 — 멱등
1810
+ url.searchParams.set(this.paramName, vuid);
1811
+ return url.toString();
1812
+ }
1813
+ handleClick(event) {
1814
+ const target = event.target;
1815
+ if (!target || typeof target.closest !== 'function')
1816
+ return;
1817
+ const anchor = target.closest('a[href]');
1818
+ if (!anchor)
1819
+ return;
1820
+ const href = anchor.getAttribute('href');
1821
+ if (!href)
1822
+ return;
1823
+ // mailto:, javascript:, tel:, # 등 non-http 스킴은 무시
1824
+ if (/^(mailto:|javascript:|tel:|sms:|#)/i.test(href))
1825
+ return;
1826
+ const decorated = this.decorate(href);
1827
+ if (decorated !== href) {
1828
+ anchor.setAttribute('href', decorated);
1829
+ }
1830
+ }
1831
+ /**
1832
+ * hostname 화이트리스트 매칭.
1833
+ * - `.example.com` → suffix match (서브도메인 포함)
1834
+ * - `example.com` → exact match
1835
+ */
1836
+ isAllowedDomain(hostname) {
1837
+ const lower = hostname.toLowerCase();
1838
+ for (const domain of this.allowedDomains) {
1839
+ if (domain.startsWith('.')) {
1840
+ // suffix match: '.vircle.co.kr'은 shop.vircle.co.kr / lounge.vircle.co.kr 모두 허용
1841
+ if (lower === domain.slice(1) || lower.endsWith(domain)) {
1842
+ return true;
1843
+ }
1844
+ }
1845
+ else if (lower === domain) {
1846
+ return true;
1847
+ }
1848
+ }
1849
+ return false;
1850
+ }
1851
+ }
1852
+
1567
1853
  /**
1568
1854
  * Web 플랫폼용 Vircle SDK
1569
1855
  *
@@ -1794,6 +2080,31 @@ class VircleWeb extends VircleCore {
1794
2080
  if (this.webConfig.singlePageApp) {
1795
2081
  this.setupSPATracking();
1796
2082
  }
2083
+ // F-4b 크로스 도메인 Link Decoration — outbound link에 ?_vuid= 자동 부착
2084
+ if (this.webConfig.linkDecoration?.enabled) {
2085
+ this.setupLinkDecoration();
2086
+ }
2087
+ }
2088
+ /**
2089
+ * F-4b Link Decoration 활성화 — outbound link click 시 ?_vuid= 자동 부착.
2090
+ * @private
2091
+ */
2092
+ setupLinkDecoration() {
2093
+ const cfg = this.webConfig.linkDecoration;
2094
+ if (!cfg?.enabled)
2095
+ return;
2096
+ const allowedDomains = cfg.allowedDomains ?? [];
2097
+ if (allowedDomains.length === 0) {
2098
+ console.warn('[Vircle] linkDecoration.enabled=true이지만 allowedDomains가 비어 있어 활성화되지 않습니다. ' +
2099
+ 'vuid 누출 방지를 위해 도메인 화이트리스트를 명시하세요.');
2100
+ return;
2101
+ }
2102
+ this.linkDecorator = new LinkDecorator({
2103
+ allowedDomains,
2104
+ paramName: cfg.paramName,
2105
+ getAnonymousId: () => this.getAnonymousId(),
2106
+ });
2107
+ this.linkDecorator.start();
1797
2108
  }
1798
2109
  /**
1799
2110
  * 에러 추적 설정
@@ -2080,6 +2391,11 @@ class VircleWeb extends VircleCore {
2080
2391
  history.replaceState = this.originalReplaceState;
2081
2392
  this.originalReplaceState = undefined;
2082
2393
  }
2394
+ // F-4b LinkDecorator click 핸들러 해제
2395
+ if (this.linkDecorator) {
2396
+ this.linkDecorator.stop();
2397
+ this.linkDecorator = undefined;
2398
+ }
2083
2399
  // Idle 작업 정리
2084
2400
  // Flush pending tasks
2085
2401
  await this.flush();
@@ -2130,5 +2446,5 @@ class VircleWeb extends VircleCore {
2130
2446
  }
2131
2447
  }
2132
2448
 
2133
- export { IndexedDBAdapter, LocalStorageAdapter, StorageFactory, VircleBrowserCompatibilityError, VircleConfigError, VircleInitializationError, VircleStorageError, VircleWeb, VircleWebError, WebContextCollector, WebExtendedCryptoAdapter, VircleWeb as default };
2449
+ export { IndexedDBAdapter, LinkDecorator, LocalStorageAdapter, StorageFactory, VircleBrowserCompatibilityError, VircleConfigError, VircleInitializationError, VircleStorageError, VircleWeb, VircleWebError, WebContextCollector, WebExtendedCryptoAdapter, VircleWeb as default };
2134
2450
  //# sourceMappingURL=index.esm.js.map