@blux.ai/web-sdk 2.2.9 → 2.2.11

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/CLAUDE.md ADDED
@@ -0,0 +1,70 @@
1
+ # sdk-web
2
+
3
+ Blux 공식 JavaScript 브라우저 클라이언트 라이브러리 (npm: @blux.ai/web-sdk).
4
+
5
+ ## 주의사항
6
+
7
+ - 브라우저 전용 — Node.js 의존성 사용 불가
8
+
9
+ ## 파일 구조
10
+
11
+ ```
12
+ src/
13
+ ├── index.ts
14
+ ├── global.d.ts
15
+ ├── BluxClient.ts # 핵심 클라이언트. 이벤트 수집/추천/인앱메시지/impression 추적 통합 관리
16
+ ├── apis/ # 서버 API 호출 함수들
17
+ │ ├── APIs.ts # API namespace (path, method, bodySchema, ResponseData 타입 정의)
18
+ │ ├── createCrmEvent.ts # CRM 이벤트 생성 (push_opened 등)
19
+ │ ├── createEvent.ts # 일반 이벤트 수집 (purchase, cartadd 등)
20
+ │ ├── getItemRecommendations.ts
21
+ │ ├── initialize.ts # SDK 초기화 (blux_user_id, device_id 발급)
22
+ │ ├── notificationsUpdate.ts # 푸시/인앱 알림 상태 업데이트
23
+ │ ├── signIn.ts # 로그인 시 user_id 연결
24
+ │ ├── signOut.ts
25
+ │ └── updateProperties.ts # 사용자 속성 업데이트
26
+ ├── bridges/ # 네이티브 앱 연동용 브릿지
27
+ │ ├── Bridge.ts # abstract 베이스. BridgeAction/BridgePlatform 타입 정의
28
+ │ ├── AndroidBridge.ts
29
+ │ ├── IosBridge.ts
30
+ │ ├── RNBridge.ts
31
+ │ └── FlutterBridge.ts
32
+ ├── constants/
33
+ │ ├── BLUX_ATTRIBUTES.ts
34
+ │ ├── COUNTRIES.ts
35
+ │ └── ISO8601_REGEX.ts
36
+ ├── events/ # 이벤트 클래스들 (Event abstract 상속)
37
+ │ ├── Event.ts # EventType enum, Event abstract 클래스
38
+ │ ├── types.ts # 이벤트 인터페이스 (IAddOrderEvent 등)
39
+ │ ├── index.ts
40
+ │ ├── VisitEvent.ts
41
+ │ ├── AddCartaddEvent.ts
42
+ │ ├── AddClickEvent.ts
43
+ │ ├── AddCustomEvent.ts
44
+ │ ├── AddInstantImpressionEvent.ts
45
+ │ ├── AddLikeEvent.ts
46
+ │ ├── AddOrderEvent.ts
47
+ │ ├── AddPageViewEvent.ts
48
+ │ ├── AddPageVisitEvent.ts
49
+ │ ├── AddPersistentImpressionEvent.ts
50
+ │ ├── AddProductDetailViewEvent.ts
51
+ │ ├── AddRateEvent.ts
52
+ │ ├── AddSearchEvent.ts
53
+ │ └── AddSectionViewEvent.ts
54
+ ├── recs/ # 추천 요청 클래스들 (Rec abstract 상속)
55
+ │ ├── Rec.ts
56
+ │ ├── types.ts
57
+ │ ├── index.ts
58
+ │ └── ItemRec.ts # UserRec, ItemRec, ItemsRec, CategoryRec 구현
59
+ └── utils/
60
+ ├── Base.ts
61
+ ├── LocalStorage.ts # 디바이스/URL/인앱 숨김 타임스탬프 저장
62
+ ├── SessionStorage.ts
63
+ ├── Logger.ts
64
+ ├── assertEqualTypes.ts
65
+ ├── getPath.ts
66
+ ├── helper.ts
67
+ ├── observables.ts # onlineAndVisible$ 등 전역 Observable
68
+ ├── operators.ts # RxJS custom operators
69
+ └── zodSchemas.ts
70
+ ```
package/context.md ADDED
@@ -0,0 +1,41 @@
1
+ # Web SDK Context
2
+
3
+ ## In-App Message 렌더링
4
+
5
+ ### Blob URL vs srcdoc
6
+
7
+ iframe 기반 인앱메시지 렌더링 시 `srcdoc` 대신 **Blob URL** 방식을 사용한다:
8
+
9
+ - `srcdoc`: 이미 파싱된 HTML을 받아서 스크립트가 정적으로 삽입됨. Next.js hydration 실패
10
+ - `Blob URL`: 실제 URL처럼 동작하여 스크립트가 순차적으로 파싱/실행됨. iOS `loadHTMLString`과 유사
11
+
12
+ ```typescript
13
+ // srcdoc (X) - hydration 실패
14
+ iframe.srcdoc = htmlString;
15
+
16
+ // Blob URL (O) - 정상 동작
17
+ const blob = new Blob([htmlString], { type: "text/html" });
18
+ iframe.src = URL.createObjectURL(blob);
19
+ ```
20
+
21
+ ### History API Polyfill
22
+
23
+ Blob URL 환경에서 `history.replaceState/pushState` 호출 시 SecurityError가 발생할 수 있다. polyfill을 `<head>`에 주입하여 해결:
24
+
25
+ ```javascript
26
+ (function () {
27
+ var origReplaceState = history.replaceState;
28
+ history.replaceState = function () {
29
+ try {
30
+ return origReplaceState.apply(this, arguments);
31
+ } catch (e) {
32
+ console.debug("[Blux] history.replaceState blocked");
33
+ }
34
+ };
35
+ // pushState도 동일하게 처리
36
+ })();
37
+ ```
38
+
39
+ ### 참고 구현
40
+
41
+ - `src/BluxClient.ts` - `displayInapp()` 메서드
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blux.ai/web-sdk",
3
- "version": "2.2.9",
3
+ "version": "2.2.11",
4
4
  "description": "The official Blux JavaScript browser client library",
5
5
  "main": "dist/src/index.js",
6
6
  "module": "dist/src/index.js",
@@ -35,6 +35,19 @@ type UserPropertiesQueueItem = {
35
35
  resolve: () => void;
36
36
  reject: (error: unknown) => void;
37
37
  };
38
+ /**
39
+ * 인앱 메시지에서 BluxBridge.triggerAction() 호출 시 전달되는 이벤트
40
+ */
41
+ export type InAppCustomActionEvent = {
42
+ /** 액션 식별자 */
43
+ actionId: string;
44
+ /** 액션에 전달된 데이터 */
45
+ data: Record<string, unknown>;
46
+ };
47
+ /**
48
+ * 인앱 커스텀 액션 핸들러 타입
49
+ */
50
+ export type InAppCustomActionHandler = (event: InAppCustomActionEvent) => void | Promise<void>;
38
51
  export declare class BluxClient {
39
52
  private readonly api;
40
53
  private readonly sdkInfo;
@@ -58,16 +71,20 @@ export declare class BluxClient {
58
71
  private lastPollTimestamp;
59
72
  private readonly bluxApplicationId;
60
73
  private readonly bluxAPIKey;
74
+ private readonly customDeviceId?;
61
75
  private readonly bridge;
62
76
  private impressedElements;
63
77
  private intersectionObservingElements;
64
78
  private readonly intersectionObserver;
65
79
  private readonly intersectionEntry$;
66
80
  private hitElements;
81
+ private currentInappCleanup;
82
+ private inAppCustomActionHandlers;
67
83
  private resolveBridge;
68
- constructor({ bluxApplicationId, bluxAPIKey, ...bridgeOptions }: {
84
+ constructor({ bluxApplicationId, bluxAPIKey, customDeviceId, ...bridgeOptions }: {
69
85
  bluxApplicationId: string;
70
86
  bluxAPIKey: string;
87
+ customDeviceId?: string;
71
88
  } & BridgeOptions, completionCallback?: (args: {
72
89
  errorMessage?: string;
73
90
  success: boolean;
@@ -89,6 +106,41 @@ export declare class BluxClient {
89
106
  customUserProperties: NonNullable<APIs.bluxUsersUpdateProperties.Body["custom_user_properties"]>;
90
107
  }): Promise<void>;
91
108
  sendEvent(event: BluxEvent | BluxEvent[]): void;
109
+ /**
110
+ * 현재 표시 중인 인앱 메시지를 프로그래밍 방식으로 닫습니다.
111
+ *
112
+ * Custom HTML 인앱에서 async 핸들러 완료 후 수동으로 닫을 때 사용합니다.
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * // BluxBridge.triggerAction('share', { url: '...' }, false); // shouldDismiss: false
117
+ * // 핸들러에서 async 작업 완료 후:
118
+ * bluxClient.dismissInApp();
119
+ * ```
120
+ */
121
+ dismissInApp(): void;
122
+ /**
123
+ * Custom HTML 인앱에서 BluxBridge.triggerAction() 호출 시 실행될 핸들러를 등록합니다.
124
+ * 여러 핸들러를 등록할 수 있으며, 등록 순서대로 실행됩니다.
125
+ *
126
+ * @param handler - 커스텀 액션 핸들러 함수
127
+ * @returns 핸들러를 제거하는 unsubscribe 함수
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * const unsubscribe = bluxClient.addInAppCustomActionHandler(async (event) => {
132
+ * if (event.actionId === 'share') {
133
+ * await navigator.share({ url: event.data.url as string });
134
+ * // async 작업 완료 후 수동으로 닫기 (shouldDismiss: false로 호출한 경우)
135
+ * bluxClient.dismissInApp();
136
+ * }
137
+ * });
138
+ *
139
+ * // 핸들러 제거
140
+ * unsubscribe();
141
+ * ```
142
+ */
143
+ addInAppCustomActionHandler(handler: InAppCustomActionHandler): () => void;
92
144
  private urlAndRef$;
93
145
  private handleUrlAndRef;
94
146
  private registerImpressionTracker;
@@ -1,7 +1,7 @@
1
1
  import axios from "axios";
2
2
  import ObjectId from "bson-objectid";
3
3
  import dayjs from "dayjs";
4
- import { BehaviorSubject, bufferTime, catchError, combineLatest, concatMap, debounceTime, filter, first, firstValueFrom, from, groupBy, merge, mergeMap, NEVER, skip, Subject, switchMap, throwError, timer, timeout, map, } from "rxjs";
4
+ import { BehaviorSubject, bufferTime, catchError, combineLatest, concatMap, debounceTime, filter, first, firstValueFrom, from, groupBy, map, merge, mergeMap, NEVER, skip, Subject, switchMap, throwError, timeout, timer, } from "rxjs";
5
5
  import packageJson from "../package.json";
6
6
  import { DevicePlatform } from "./apis/APIs";
7
7
  import { createCrmEvent } from "./apis/createCrmEvent";
@@ -12,7 +12,9 @@ import { notificationsUpdate } from "./apis/notificationsUpdate";
12
12
  import { signIn } from "./apis/signIn";
13
13
  import { signOut } from "./apis/signOut";
14
14
  import { updateProperties } from "./apis/updateProperties";
15
+ import { AndroidBridge } from "./bridges/AndroidBridge";
15
16
  import { FlutterBridge } from "./bridges/FlutterBridge";
17
+ import { IosBridge } from "./bridges/IosBridge";
16
18
  import { RNBridge } from "./bridges/RNBridge";
17
19
  import { BLUX_ATTRIBUTES } from "./constants/BLUX_ATTRIBUTES";
18
20
  import { AddClickEvent, AddPageVisitEvent, AddSectionViewEvent, VisitEvent, } from "./events";
@@ -43,6 +45,7 @@ export class BluxClient {
43
45
  lastPollTimestamp = Date.now();
44
46
  bluxApplicationId;
45
47
  bluxAPIKey;
48
+ customDeviceId;
46
49
  bridge;
47
50
  // Impression 관련 변수
48
51
  impressedElements = [];
@@ -55,6 +58,10 @@ export class BluxClient {
55
58
  intersectionEntry$ = new Subject();
56
59
  // Hit 관련 변수
57
60
  hitElements = [];
61
+ // 현재 표시 중인 인앱 메시지의 cleanup 함수
62
+ currentInappCleanup = null;
63
+ // 인앱 커스텀 액션 핸들러 (복수 등록 가능)
64
+ inAppCustomActionHandlers = new Set();
58
65
  resolveBridge(bridgeOptions) {
59
66
  if ("useRNBridge" in bridgeOptions && bridgeOptions.useRNBridge) {
60
67
  return new RNBridge();
@@ -63,6 +70,10 @@ export class BluxClient {
63
70
  switch (bridgeOptions.bridgePlatform) {
64
71
  case "none":
65
72
  return null;
73
+ case "android":
74
+ return new AndroidBridge();
75
+ case "ios":
76
+ return new IosBridge();
66
77
  case "react-native":
67
78
  return new RNBridge();
68
79
  case "flutter":
@@ -77,9 +88,10 @@ export class BluxClient {
77
88
  }
78
89
  return null;
79
90
  }
80
- constructor({ bluxApplicationId, bluxAPIKey, ...bridgeOptions }, completionCallback) {
91
+ constructor({ bluxApplicationId, bluxAPIKey, customDeviceId, ...bridgeOptions }, completionCallback) {
81
92
  this.bluxApplicationId = bluxApplicationId;
82
93
  this.bluxAPIKey = bluxAPIKey;
94
+ this.customDeviceId = customDeviceId;
83
95
  const baseUrl = window?._BLUX_SDK_?.baseUrl ?? "https://api.blux.ai/prod";
84
96
  this.api.defaults.baseURL = baseUrl;
85
97
  this.bridge = this.resolveBridge(bridgeOptions);
@@ -213,15 +225,31 @@ export class BluxClient {
213
225
  if (isShouldSkip)
214
226
  return resolve(true);
215
227
  const iframeElement = document.createElement("iframe");
216
- const params = new URLSearchParams({
217
- inapp_id: inapp.inappId,
218
- application_id: this.bluxApplicationId,
219
- stage: this.api.defaults.baseURL === "https://api.blux.ai/prod"
220
- ? "prod"
221
- : "stg",
222
- sdk_version: this.sdkInfo.version,
223
- });
224
- iframeElement.src = `${inapp.baseUrl}?${params.toString()}`;
228
+ // Blob URL 방식: 실제 URL 컨텍스트를 가지므로 Next.js가 정상 작동
229
+ // <base> 태그로 외부 리소스 경로 설정
230
+ // history.replaceState/pushState 에러 방지를 위한 polyfill 추가
231
+ const historyPolyfill = `<script>
232
+ (function() {
233
+ var origReplaceState = history.replaceState;
234
+ var origPushState = history.pushState;
235
+ history.replaceState = function() {
236
+ try { return origReplaceState.apply(this, arguments); }
237
+ catch(e) { console.debug('[Blux] history.replaceState blocked in blob context'); }
238
+ };
239
+ history.pushState = function() {
240
+ try { return origPushState.apply(this, arguments); }
241
+ catch(e) { console.debug('[Blux] history.pushState blocked in blob context'); }
242
+ };
243
+ })();
244
+ </script>`;
245
+ const headInjection = `${historyPolyfill}<base href="${inapp.baseUrl}/">`;
246
+ const htmlWithBase = inapp.htmlString.includes("<head>")
247
+ ? inapp.htmlString.replace("<head>", `<head>${headInjection}`)
248
+ : inapp.htmlString;
249
+ const blob = new Blob([htmlWithBase], { type: "text/html" });
250
+ const blobUrl = URL.createObjectURL(blob);
251
+ iframeElement.sandbox.add("allow-scripts", "allow-same-origin", "allow-forms");
252
+ iframeElement.src = blobUrl;
225
253
  iframeElement.style.position = "fixed";
226
254
  iframeElement.style.width = `${window.innerWidth}px`;
227
255
  iframeElement.style.height = `${window.innerHeight}px`;
@@ -232,13 +260,17 @@ export class BluxClient {
232
260
  document.body.appendChild(iframeElement);
233
261
  let urlChangeSubscription = null;
234
262
  const cleanup = () => {
263
+ URL.revokeObjectURL(blobUrl);
235
264
  iframeElement.remove();
236
265
  window.removeEventListener("message", handleMessage);
237
266
  window.visualViewport?.removeEventListener("resize", handleViewportChange);
238
267
  window.visualViewport?.removeEventListener("scroll", handleViewportChange);
239
268
  urlChangeSubscription?.unsubscribe();
269
+ this.currentInappCleanup = null;
240
270
  resolve(inapp.inappId);
241
271
  };
272
+ // 현재 인앱 cleanup 함수 저장
273
+ this.currentInappCleanup = cleanup;
242
274
  const handleViewportChange = () => {
243
275
  const width = window.visualViewport?.width ?? window.innerWidth;
244
276
  const height = window.visualViewport?.height ?? window.innerHeight;
@@ -324,6 +356,37 @@ export class BluxClient {
324
356
  // opened_by_iframe && open_in_new_tab인 경우: iframe에서 이미 새 탭 열었으므로 스킵
325
357
  }
326
358
  }
359
+ else if (message.action === "custom_action") {
360
+ const { action_id, data, should_dismiss } = message.data;
361
+ // 등록된 모든 핸들러 호출
362
+ for (const handler of this.inAppCustomActionHandlers) {
363
+ try {
364
+ handler({ actionId: action_id, data });
365
+ }
366
+ catch (error) {
367
+ Logger.error(error, {
368
+ tags: { from: "inAppCustomActionHandler" },
369
+ });
370
+ }
371
+ }
372
+ // should_dismiss가 false면 cleanup하지 않음
373
+ if (!should_dismiss) {
374
+ return;
375
+ }
376
+ }
377
+ else if (message.action === "inapp_opened") {
378
+ // data-blux-click 속성으로 인한 클릭 이벤트 추적
379
+ if (bluxUser) {
380
+ this.enqueueRequest(() => createCrmEvent(this.api, {
381
+ application_id: this.bluxApplicationId,
382
+ }, {
383
+ notification_id: inapp.notificationId,
384
+ crm_event_type: "inapp_opened",
385
+ captured_at: new Date().toISOString(),
386
+ }, this.generateApiHeader(bluxUser)));
387
+ }
388
+ return; // 인앱메시지를 닫지 않음
389
+ }
327
390
  cleanup();
328
391
  };
329
392
  // URL 변경 시 인앱메시지 닫기 (SPA 뒤로가기/앞으로가기 대응)
@@ -355,6 +418,7 @@ export class BluxClient {
355
418
  this.bridge.initialize({
356
419
  bluxApplicationId: this.bluxApplicationId,
357
420
  bluxAPIKey: this.bluxAPIKey,
421
+ customDeviceId: this.customDeviceId,
358
422
  });
359
423
  return;
360
424
  }
@@ -411,6 +475,7 @@ export class BluxClient {
411
475
  sdk_type: this.sdkInfo.type,
412
476
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
413
477
  blux_user_id: blux_user_id ?? undefined,
478
+ custom_device_id: this.customDeviceId,
414
479
  }, this.generateApiHeader({
415
480
  bluxAPIKey: this.bluxAPIKey,
416
481
  }));
@@ -531,6 +596,48 @@ export class BluxClient {
531
596
  Logger.error(error, { tags: { from: "sendEvent" } });
532
597
  }
533
598
  }
599
+ /**
600
+ * 현재 표시 중인 인앱 메시지를 프로그래밍 방식으로 닫습니다.
601
+ *
602
+ * Custom HTML 인앱에서 async 핸들러 완료 후 수동으로 닫을 때 사용합니다.
603
+ *
604
+ * @example
605
+ * ```typescript
606
+ * // BluxBridge.triggerAction('share', { url: '...' }, false); // shouldDismiss: false
607
+ * // 핸들러에서 async 작업 완료 후:
608
+ * bluxClient.dismissInApp();
609
+ * ```
610
+ */
611
+ dismissInApp() {
612
+ this.currentInappCleanup?.();
613
+ }
614
+ /**
615
+ * Custom HTML 인앱에서 BluxBridge.triggerAction() 호출 시 실행될 핸들러를 등록합니다.
616
+ * 여러 핸들러를 등록할 수 있으며, 등록 순서대로 실행됩니다.
617
+ *
618
+ * @param handler - 커스텀 액션 핸들러 함수
619
+ * @returns 핸들러를 제거하는 unsubscribe 함수
620
+ *
621
+ * @example
622
+ * ```typescript
623
+ * const unsubscribe = bluxClient.addInAppCustomActionHandler(async (event) => {
624
+ * if (event.actionId === 'share') {
625
+ * await navigator.share({ url: event.data.url as string });
626
+ * // async 작업 완료 후 수동으로 닫기 (shouldDismiss: false로 호출한 경우)
627
+ * bluxClient.dismissInApp();
628
+ * }
629
+ * });
630
+ *
631
+ * // 핸들러 제거
632
+ * unsubscribe();
633
+ * ```
634
+ */
635
+ addInAppCustomActionHandler(handler) {
636
+ this.inAppCustomActionHandlers.add(handler);
637
+ return () => {
638
+ this.inAppCustomActionHandlers.delete(handler);
639
+ };
640
+ }
534
641
  urlAndRef$ = new BehaviorSubject(undefined);
535
642
  handleUrlAndRef() {
536
643
  // set url and ref to local storage