@vircle/sdk-web 0.2.0 → 0.2.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 +476 -138
- package/dist/index.d.ts +481 -245
- package/dist/index.esm.js +852 -258
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +854 -261
- package/dist/index.js.map +1 -1
- package/dist/vircle-web-sdk.min.js +1 -1
- package/dist/vircle-web-sdk.min.js.map +1 -1
- package/dist/vircle-web-sdk.standalone.esm.js +1 -1
- package/dist/vircle-web-sdk.standalone.esm.js.map +1 -1
- package/package.json +79 -79
package/dist/index.js
CHANGED
|
@@ -422,6 +422,12 @@ class LocalStorageAdapter {
|
|
|
422
422
|
getKey(key) {
|
|
423
423
|
return `${this.prefix}${key}`;
|
|
424
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* 전체 키 반환 (접두사 포함)
|
|
427
|
+
*/
|
|
428
|
+
getFullKey(key) {
|
|
429
|
+
return this.getKey(key);
|
|
430
|
+
}
|
|
425
431
|
/**
|
|
426
432
|
* 값 저장
|
|
427
433
|
*
|
|
@@ -497,15 +503,22 @@ class LocalStorageAdapter {
|
|
|
497
503
|
if (data === null) {
|
|
498
504
|
return null;
|
|
499
505
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
try {
|
|
507
|
+
const parsed = JSON.parse(data);
|
|
508
|
+
// 구조가 올바른지 확인 (이전 버전 호환성)
|
|
509
|
+
if (parsed && typeof parsed === 'object' && 'value' in parsed && 'timestamp' in parsed) {
|
|
510
|
+
return parsed.value;
|
|
511
|
+
}
|
|
512
|
+
// 이전 버전 데이터 처리
|
|
513
|
+
return parsed;
|
|
514
|
+
}
|
|
515
|
+
catch (parseError) {
|
|
516
|
+
console.warn(`[Vircle] Failed to parse stored value for key "${key}":`, parseError);
|
|
517
|
+
return null;
|
|
504
518
|
}
|
|
505
|
-
// 이전 버전 데이터 처리
|
|
506
|
-
return parsed;
|
|
507
519
|
}
|
|
508
|
-
catch {
|
|
520
|
+
catch (error) {
|
|
521
|
+
console.warn(`[Vircle] Failed to get value from localStorage for key "${key}":`, error);
|
|
509
522
|
return null;
|
|
510
523
|
}
|
|
511
524
|
}
|
|
@@ -564,10 +577,17 @@ class LocalStorageAdapter {
|
|
|
564
577
|
// localStorage의 모든 키를 한번에 가져와서 필터링 (성능 최적화)
|
|
565
578
|
const allKeys = Object.keys(localStorage);
|
|
566
579
|
const keysToRemove = allKeys.filter((key) => key.startsWith(this.prefix));
|
|
567
|
-
keysToRemove.forEach((key) =>
|
|
580
|
+
keysToRemove.forEach((key) => {
|
|
581
|
+
try {
|
|
582
|
+
localStorage.removeItem(key);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
console.warn(`[Vircle] Failed to remove key "${key}" during clear:`, error);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
568
588
|
}
|
|
569
|
-
catch {
|
|
570
|
-
|
|
589
|
+
catch (error) {
|
|
590
|
+
console.warn('[Vircle] Failed to clear localStorage:', error);
|
|
571
591
|
}
|
|
572
592
|
}
|
|
573
593
|
/**
|
|
@@ -715,271 +735,791 @@ class LocalStorageAdapter {
|
|
|
715
735
|
}
|
|
716
736
|
|
|
717
737
|
/**
|
|
718
|
-
*
|
|
738
|
+
* IndexedDB를 사용하는 비동기 스토리지 어댑터
|
|
739
|
+
*
|
|
740
|
+
* @description
|
|
741
|
+
* 브라우저의 IndexedDB API를 활용하여 대용량 데이터를 비동기적으로 저장합니다.
|
|
742
|
+
* LocalStorage와 달리 메인 스레드를 차단하지 않으며, 더 큰 용량을 지원합니다.
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* ```typescript
|
|
746
|
+
* const storage = new IndexedDBAdapter('vircle_')
|
|
747
|
+
* await storage.set('user_id', '12345')
|
|
748
|
+
* const userId = await storage.get('user_id')
|
|
749
|
+
* ```
|
|
719
750
|
*/
|
|
720
|
-
class
|
|
721
|
-
constructor() {
|
|
722
|
-
this.
|
|
751
|
+
class IndexedDBAdapter {
|
|
752
|
+
constructor(prefix = 'vircle_') {
|
|
753
|
+
this.db = null;
|
|
754
|
+
this.storeName = 'vircle_store';
|
|
755
|
+
this.dbVersion = 1;
|
|
756
|
+
this.initPromise = null;
|
|
757
|
+
this.isClosed = false;
|
|
758
|
+
this.prefix = prefix;
|
|
759
|
+
this.dbName = `${prefix}db`;
|
|
723
760
|
}
|
|
724
761
|
/**
|
|
725
|
-
*
|
|
762
|
+
* IndexedDB 초기화
|
|
763
|
+
*
|
|
764
|
+
* @description
|
|
765
|
+
* IndexedDB 연결을 초기화하고 스토어를 생성합니다.
|
|
766
|
+
* 여러 번 호출되어도 한 번만 초기화됩니다.
|
|
767
|
+
*
|
|
768
|
+
* @private
|
|
769
|
+
* @returns {Promise<void>}
|
|
726
770
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
771
|
+
async init() {
|
|
772
|
+
// 명시적으로 닫힌 경우 에러 발생
|
|
773
|
+
if (this.isClosed) {
|
|
774
|
+
throw new VircleStorageError('IndexedDB adapter가 이미 닫혔습니다');
|
|
775
|
+
}
|
|
776
|
+
// 이미 초기화 중이면 기다림
|
|
777
|
+
if (this.initPromise) {
|
|
778
|
+
return this.initPromise;
|
|
730
779
|
}
|
|
731
|
-
//
|
|
732
|
-
this.
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
this.
|
|
737
|
-
|
|
738
|
-
|
|
780
|
+
// 이미 초기화됨
|
|
781
|
+
if (this.db) {
|
|
782
|
+
return Promise.resolve();
|
|
783
|
+
}
|
|
784
|
+
// 동시성 문제 방지를 위해 즉시 Promise 할당
|
|
785
|
+
this.initPromise = this.openDB();
|
|
786
|
+
try {
|
|
787
|
+
await this.initPromise;
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
// 초기화 실패 시 Promise 초기화하여 재시도 가능하도록 함
|
|
791
|
+
this.initPromise = null;
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
794
|
+
return this.initPromise;
|
|
739
795
|
}
|
|
740
796
|
/**
|
|
741
|
-
*
|
|
797
|
+
* IndexedDB 연결 열기
|
|
798
|
+
*
|
|
799
|
+
* @private
|
|
800
|
+
* @returns {Promise<void>}
|
|
742
801
|
*/
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
this.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
802
|
+
async openDB() {
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
805
|
+
request.onerror = () => {
|
|
806
|
+
reject(new VircleStorageError('IndexedDB 열기 실패', {
|
|
807
|
+
error: request.error?.message,
|
|
808
|
+
}));
|
|
809
|
+
};
|
|
810
|
+
request.onsuccess = () => {
|
|
811
|
+
this.db = request.result;
|
|
812
|
+
resolve();
|
|
813
|
+
};
|
|
814
|
+
request.onupgradeneeded = (event) => {
|
|
815
|
+
const db = event.target.result;
|
|
816
|
+
// 스토어가 없으면 생성
|
|
817
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
818
|
+
const store = db.createObjectStore(this.storeName, { keyPath: 'key' });
|
|
819
|
+
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
});
|
|
749
823
|
}
|
|
750
824
|
/**
|
|
751
|
-
*
|
|
825
|
+
* 키에 접두사 추가
|
|
826
|
+
*
|
|
827
|
+
* @private
|
|
828
|
+
* @param {string} key - 원본 키
|
|
829
|
+
* @returns {string} 접두사가 추가된 키
|
|
752
830
|
*/
|
|
753
|
-
|
|
754
|
-
return
|
|
755
|
-
typeof performance !== 'undefined' &&
|
|
756
|
-
typeof performance.mark === 'function');
|
|
831
|
+
getKey(key) {
|
|
832
|
+
return `${this.prefix}${key}`;
|
|
757
833
|
}
|
|
758
834
|
/**
|
|
759
|
-
*
|
|
835
|
+
* 값 저장
|
|
836
|
+
*
|
|
837
|
+
* @description
|
|
838
|
+
* IndexedDB에 값을 비동기적으로 저장합니다.
|
|
839
|
+
* 메인 스레드를 차단하지 않아 성능이 향상됩니다.
|
|
840
|
+
*
|
|
841
|
+
* @template T - 저장할 값의 타입
|
|
842
|
+
* @param {string} key - 키
|
|
843
|
+
* @param {T} value - 저장할 값
|
|
844
|
+
* @returns {Promise<void>}
|
|
760
845
|
*/
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
846
|
+
async set(key, value) {
|
|
847
|
+
await this.init();
|
|
848
|
+
return new Promise((resolve, reject) => {
|
|
849
|
+
if (!this.db) {
|
|
850
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
854
|
+
const store = transaction.objectStore(this.storeName);
|
|
855
|
+
const item = {
|
|
856
|
+
key: this.getKey(key),
|
|
857
|
+
value,
|
|
858
|
+
timestamp: Date.now(),
|
|
859
|
+
};
|
|
860
|
+
const request = store.put(item);
|
|
861
|
+
request.onerror = () => {
|
|
862
|
+
reject(new VircleStorageError('IndexedDB 저장 실패', {
|
|
863
|
+
key,
|
|
864
|
+
error: request.error?.message,
|
|
865
|
+
}));
|
|
866
|
+
};
|
|
867
|
+
request.onsuccess = () => {
|
|
868
|
+
resolve();
|
|
869
|
+
};
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* 값 가져오기
|
|
874
|
+
*
|
|
875
|
+
* @description
|
|
876
|
+
* IndexedDB에서 값을 비동기적으로 가져옵니다.
|
|
877
|
+
*
|
|
878
|
+
* @template T - 반환할 값의 타입
|
|
879
|
+
* @param {string} key - 키
|
|
880
|
+
* @returns {Promise<T | null>} 저장된 값 또는 null
|
|
881
|
+
*/
|
|
882
|
+
async get(key) {
|
|
883
|
+
await this.init();
|
|
884
|
+
return new Promise((resolve, reject) => {
|
|
885
|
+
if (!this.db) {
|
|
886
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
890
|
+
const store = transaction.objectStore(this.storeName);
|
|
891
|
+
const request = store.get(this.getKey(key));
|
|
892
|
+
request.onerror = () => {
|
|
893
|
+
reject(new VircleStorageError('IndexedDB 읽기 실패', {
|
|
894
|
+
key,
|
|
895
|
+
error: request.error?.message,
|
|
896
|
+
}));
|
|
897
|
+
};
|
|
898
|
+
request.onsuccess = () => {
|
|
899
|
+
const result = request.result;
|
|
900
|
+
if (result && 'value' in result) {
|
|
901
|
+
resolve(result.value);
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
resolve(null);
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* 값 삭제
|
|
911
|
+
*
|
|
912
|
+
* @description
|
|
913
|
+
* IndexedDB에서 특정 키의 값을 삭제합니다.
|
|
914
|
+
*
|
|
915
|
+
* @param {string} key - 키
|
|
916
|
+
* @returns {Promise<boolean>} 삭제 성공 여부
|
|
917
|
+
*/
|
|
918
|
+
async remove(key) {
|
|
919
|
+
await this.init();
|
|
920
|
+
return new Promise((resolve, reject) => {
|
|
921
|
+
if (!this.db) {
|
|
922
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
926
|
+
const store = transaction.objectStore(this.storeName);
|
|
927
|
+
const request = store.delete(this.getKey(key));
|
|
928
|
+
request.onerror = () => {
|
|
929
|
+
reject(new VircleStorageError('IndexedDB 삭제 실패', {
|
|
930
|
+
key,
|
|
931
|
+
error: request.error?.message,
|
|
932
|
+
}));
|
|
933
|
+
};
|
|
934
|
+
request.onsuccess = () => {
|
|
935
|
+
resolve(true);
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* 모든 값 삭제
|
|
941
|
+
*
|
|
942
|
+
* @description
|
|
943
|
+
* 현재 접두사로 시작하는 모든 키의 값을 삭제합니다.
|
|
944
|
+
*
|
|
945
|
+
* @returns {Promise<void>}
|
|
946
|
+
*/
|
|
947
|
+
async clear() {
|
|
948
|
+
await this.init();
|
|
949
|
+
return new Promise((resolve, reject) => {
|
|
950
|
+
if (!this.db) {
|
|
951
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
955
|
+
const store = transaction.objectStore(this.storeName);
|
|
956
|
+
// 모든 키를 가져와서 prefix로 필터링
|
|
957
|
+
const request = store.openCursor();
|
|
958
|
+
const keysToDelete = [];
|
|
959
|
+
request.onsuccess = () => {
|
|
960
|
+
const cursor = request.result;
|
|
961
|
+
if (cursor) {
|
|
962
|
+
if (cursor.key.toString().startsWith(this.prefix)) {
|
|
963
|
+
keysToDelete.push(cursor.key);
|
|
775
964
|
}
|
|
965
|
+
cursor.continue();
|
|
776
966
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
967
|
+
else {
|
|
968
|
+
// 모든 키 순회 완료, 삭제 시작
|
|
969
|
+
const deletePromises = keysToDelete.map(key => {
|
|
970
|
+
return new Promise((resolveDelete, rejectDelete) => {
|
|
971
|
+
const deleteRequest = store.delete(key);
|
|
972
|
+
deleteRequest.onsuccess = () => resolveDelete();
|
|
973
|
+
deleteRequest.onerror = () => rejectDelete(deleteRequest.error);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
Promise.all(deletePromises)
|
|
977
|
+
.then(() => resolve())
|
|
978
|
+
.catch(error => reject(new VircleStorageError('일부 항목 삭제 실패', { error })));
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
request.onerror = () => {
|
|
982
|
+
reject(new VircleStorageError('IndexedDB 커서 열기 실패', {
|
|
983
|
+
error: request.error?.message,
|
|
984
|
+
}));
|
|
985
|
+
};
|
|
986
|
+
});
|
|
783
987
|
}
|
|
784
988
|
/**
|
|
785
|
-
*
|
|
989
|
+
* 키 존재 여부 확인
|
|
990
|
+
*
|
|
991
|
+
* @param {string} key - 키
|
|
992
|
+
* @returns {Promise<boolean>} 존재 여부
|
|
786
993
|
*/
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
994
|
+
async has(key) {
|
|
995
|
+
const value = await this.get(key);
|
|
996
|
+
return value !== null;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* 저장된 항목 수
|
|
1000
|
+
*
|
|
1001
|
+
* @description
|
|
1002
|
+
* 현재 접두사로 저장된 모든 항목의 개수를 반환합니다.
|
|
1003
|
+
*
|
|
1004
|
+
* @returns {Promise<number>} 항목 수
|
|
1005
|
+
*/
|
|
1006
|
+
async size() {
|
|
1007
|
+
await this.init();
|
|
1008
|
+
return new Promise((resolve, reject) => {
|
|
1009
|
+
if (!this.db) {
|
|
1010
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
1014
|
+
const store = transaction.objectStore(this.storeName);
|
|
1015
|
+
const request = store.openCursor();
|
|
1016
|
+
let count = 0;
|
|
1017
|
+
request.onsuccess = () => {
|
|
1018
|
+
const cursor = request.result;
|
|
1019
|
+
if (cursor) {
|
|
1020
|
+
if (cursor.key.toString().startsWith(this.prefix)) {
|
|
1021
|
+
count++;
|
|
797
1022
|
}
|
|
1023
|
+
cursor.continue();
|
|
798
1024
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1025
|
+
else {
|
|
1026
|
+
resolve(count);
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
request.onerror = () => {
|
|
1030
|
+
reject(new VircleStorageError('IndexedDB 카운트 실패', {
|
|
1031
|
+
error: request.error?.message,
|
|
1032
|
+
}));
|
|
1033
|
+
};
|
|
1034
|
+
});
|
|
805
1035
|
}
|
|
806
1036
|
/**
|
|
807
|
-
*
|
|
1037
|
+
* 모든 키 반환
|
|
1038
|
+
*
|
|
1039
|
+
* @description
|
|
1040
|
+
* 현재 접두사로 저장된 모든 키를 배열로 반환합니다.
|
|
1041
|
+
*
|
|
1042
|
+
* @returns {Promise<string[]>} 키 배열
|
|
808
1043
|
*/
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1044
|
+
async keys() {
|
|
1045
|
+
await this.init();
|
|
1046
|
+
return new Promise((resolve, reject) => {
|
|
1047
|
+
if (!this.db) {
|
|
1048
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
1052
|
+
const store = transaction.objectStore(this.storeName);
|
|
1053
|
+
const request = store.openCursor();
|
|
1054
|
+
const keys = [];
|
|
1055
|
+
request.onsuccess = () => {
|
|
1056
|
+
const cursor = request.result;
|
|
1057
|
+
if (cursor) {
|
|
1058
|
+
const key = cursor.key.toString();
|
|
1059
|
+
if (key.startsWith(this.prefix)) {
|
|
1060
|
+
keys.push(key.substring(this.prefix.length));
|
|
1061
|
+
}
|
|
1062
|
+
cursor.continue();
|
|
818
1063
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1064
|
+
else {
|
|
1065
|
+
resolve(keys);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
request.onerror = () => {
|
|
1069
|
+
reject(new VircleStorageError('IndexedDB 키 조회 실패', {
|
|
1070
|
+
error: request.error?.message,
|
|
1071
|
+
}));
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
825
1074
|
}
|
|
826
1075
|
/**
|
|
827
|
-
*
|
|
1076
|
+
* 오래된 항목 정리 (LRU 방식)
|
|
1077
|
+
*
|
|
1078
|
+
* @description
|
|
1079
|
+
* 용량이 부족할 때 타임스탬프 기준으로 오래된 항목을 삭제합니다.
|
|
1080
|
+
*
|
|
1081
|
+
* @param {number} percentage - 삭제할 비율 (0-1)
|
|
1082
|
+
* @returns {Promise<void>}
|
|
828
1083
|
*/
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1084
|
+
async clearOldItems(percentage = 0.2) {
|
|
1085
|
+
await this.init();
|
|
1086
|
+
return new Promise((resolve, reject) => {
|
|
1087
|
+
if (!this.db) {
|
|
1088
|
+
reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
1092
|
+
const store = transaction.objectStore(this.storeName);
|
|
1093
|
+
const index = store.index('timestamp');
|
|
1094
|
+
const request = index.openCursor();
|
|
1095
|
+
const items = [];
|
|
1096
|
+
request.onsuccess = async () => {
|
|
1097
|
+
const cursor = request.result;
|
|
1098
|
+
if (cursor) {
|
|
1099
|
+
const primaryKey = cursor.primaryKey;
|
|
1100
|
+
if (primaryKey.startsWith(this.prefix)) {
|
|
1101
|
+
items.push({
|
|
1102
|
+
key: primaryKey,
|
|
1103
|
+
timestamp: cursor.value.timestamp,
|
|
1104
|
+
});
|
|
839
1105
|
}
|
|
1106
|
+
cursor.continue();
|
|
840
1107
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1108
|
+
else {
|
|
1109
|
+
// 타임스탬프로 정렬
|
|
1110
|
+
items.sort((a, b) => a.timestamp - b.timestamp);
|
|
1111
|
+
// 삭제할 개수 계산
|
|
1112
|
+
const deleteCount = Math.max(1, Math.floor(items.length * percentage));
|
|
1113
|
+
const keysToDelete = items.slice(0, deleteCount).map(item => item.key);
|
|
1114
|
+
// 삭제 실행
|
|
1115
|
+
const deletePromises = keysToDelete.map(key => {
|
|
1116
|
+
return new Promise((resolveDelete, rejectDelete) => {
|
|
1117
|
+
const deleteRequest = store.delete(key);
|
|
1118
|
+
deleteRequest.onsuccess = () => resolveDelete();
|
|
1119
|
+
deleteRequest.onerror = () => rejectDelete(deleteRequest.error);
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
Promise.all(deletePromises)
|
|
1123
|
+
.then(() => resolve())
|
|
1124
|
+
.catch(error => reject(new VircleStorageError('일부 항목 삭제 실패', { error })));
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
request.onerror = () => {
|
|
1128
|
+
reject(new VircleStorageError('IndexedDB 인덱스 조회 실패', {
|
|
1129
|
+
error: request.error?.message,
|
|
1130
|
+
}));
|
|
1131
|
+
};
|
|
1132
|
+
});
|
|
847
1133
|
}
|
|
848
1134
|
/**
|
|
849
|
-
*
|
|
850
|
-
*
|
|
1135
|
+
* 연결 종료
|
|
1136
|
+
*
|
|
1137
|
+
* @description
|
|
1138
|
+
* IndexedDB 연결을 명시적으로 종료합니다.
|
|
1139
|
+
*
|
|
1140
|
+
* @returns {Promise<void>}
|
|
851
1141
|
*/
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1142
|
+
async close() {
|
|
1143
|
+
this.isClosed = true;
|
|
1144
|
+
if (this.db) {
|
|
1145
|
+
this.db.close();
|
|
1146
|
+
this.db = null;
|
|
855
1147
|
}
|
|
856
|
-
|
|
857
|
-
this.marks.set(name, performance.now());
|
|
1148
|
+
this.initPromise = null;
|
|
858
1149
|
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* 스토리지 어댑터 팩토리
|
|
1154
|
+
*
|
|
1155
|
+
* @description
|
|
1156
|
+
* 환경과 요구사항에 맞는 최적의 스토리지 어댑터를 생성합니다.
|
|
1157
|
+
* IndexedDB를 우선 사용하고, 지원하지 않으면 LocalStorage로 폴백합니다.
|
|
1158
|
+
*/
|
|
1159
|
+
class StorageFactory {
|
|
859
1160
|
/**
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
* @
|
|
1161
|
+
* 스토리지 어댑터 생성 (동기)
|
|
1162
|
+
*
|
|
1163
|
+
* @description
|
|
1164
|
+
* 동기적으로 스토리지 어댑터를 생성합니다.
|
|
1165
|
+
* 'auto' 모드에서는 API 존재 여부만 확인하고 IndexedDB를 우선 사용합니다.
|
|
1166
|
+
* Safari Private 모드 등에서 실제 사용 불가 시 런타임 에러가 발생할 수 있습니다.
|
|
1167
|
+
*
|
|
1168
|
+
* @param {string} prefix - 키 접두사
|
|
1169
|
+
* @param {StorageType} type - 스토리지 타입
|
|
1170
|
+
* @returns {StorageAdapter} 생성된 스토리지 어댑터
|
|
863
1171
|
*/
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1172
|
+
static create(prefix = 'vircle_', type = 'auto') {
|
|
1173
|
+
switch (type) {
|
|
1174
|
+
case 'localStorage':
|
|
1175
|
+
return new LocalStorageAdapter(prefix);
|
|
1176
|
+
case 'indexedDB':
|
|
1177
|
+
return new IndexedDBAdapter(prefix);
|
|
1178
|
+
case 'auto':
|
|
1179
|
+
default:
|
|
1180
|
+
return this.createAuto(prefix);
|
|
867
1181
|
}
|
|
868
|
-
const startTime = this.marks.get(name);
|
|
869
|
-
const duration = performance.now() - startTime;
|
|
870
|
-
performance.mark(`vircle_${name}_end`);
|
|
871
|
-
performance.measure(`vircle_${name}`, `vircle_${name}_start`, `vircle_${name}_end`);
|
|
872
|
-
this.marks.delete(name);
|
|
873
|
-
return duration;
|
|
874
1182
|
}
|
|
875
1183
|
/**
|
|
876
|
-
*
|
|
1184
|
+
* 스토리지 어댑터 생성 (비동기, 권장)
|
|
1185
|
+
*
|
|
1186
|
+
* @description
|
|
1187
|
+
* 비동기적으로 스토리지 어댑터를 생성합니다.
|
|
1188
|
+
* 'auto' 모드에서는 실제 IndexedDB 사용 가능 여부를 테스트한 후 결정합니다.
|
|
1189
|
+
* Safari Private 모드 등에서도 안전하게 LocalStorage로 폴백됩니다.
|
|
1190
|
+
*
|
|
1191
|
+
* @param {string} prefix - 키 접두사
|
|
1192
|
+
* @param {StorageType} type - 스토리지 타입
|
|
1193
|
+
* @returns {Promise<StorageAdapter>} 생성된 스토리지 어댑터
|
|
877
1194
|
*/
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1195
|
+
static async createAsync(prefix = 'vircle_', type = 'auto') {
|
|
1196
|
+
switch (type) {
|
|
1197
|
+
case 'localStorage':
|
|
1198
|
+
return new LocalStorageAdapter(prefix);
|
|
1199
|
+
case 'indexedDB':
|
|
1200
|
+
return new IndexedDBAdapter(prefix);
|
|
1201
|
+
case 'auto':
|
|
1202
|
+
default:
|
|
1203
|
+
return this.createAutoAsync(prefix);
|
|
881
1204
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 자동으로 최적의 스토리지 선택 (비동기)
|
|
1208
|
+
*
|
|
1209
|
+
* @private
|
|
1210
|
+
* @param {string} prefix - 키 접두사
|
|
1211
|
+
* @returns {Promise<StorageAdapter>} 선택된 스토리지 어댑터
|
|
1212
|
+
*/
|
|
1213
|
+
static async createAutoAsync(prefix) {
|
|
1214
|
+
// IndexedDB 실제 사용 가능 여부 확인 (비동기)
|
|
1215
|
+
const isIndexedDBAvailable = await this.isIndexedDBAvailableAsync();
|
|
1216
|
+
if (isIndexedDBAvailable) {
|
|
1217
|
+
return new IndexedDBAdapter(prefix);
|
|
891
1218
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1219
|
+
return new LocalStorageAdapter(prefix);
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* 자동으로 최적의 스토리지 선택
|
|
1223
|
+
*
|
|
1224
|
+
* @private
|
|
1225
|
+
* @param {string} prefix - 키 접두사
|
|
1226
|
+
* @returns {StorageAdapter} 선택된 스토리지 어댑터
|
|
1227
|
+
*/
|
|
1228
|
+
static createAuto(prefix) {
|
|
1229
|
+
// IndexedDB 지원 여부 확인
|
|
1230
|
+
if (this.isIndexedDBAvailable()) {
|
|
1231
|
+
try {
|
|
1232
|
+
const adapter = new IndexedDBAdapter(prefix);
|
|
1233
|
+
return adapter;
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
console.warn('[Vircle] IndexedDB 초기화 실패, LocalStorage로 폴백합니다:', error);
|
|
1237
|
+
}
|
|
895
1238
|
}
|
|
896
|
-
//
|
|
897
|
-
|
|
898
|
-
|
|
1239
|
+
// LocalStorage로 폴백
|
|
1240
|
+
return new LocalStorageAdapter(prefix);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* IndexedDB 사용 가능 여부 확인 (동기)
|
|
1244
|
+
*
|
|
1245
|
+
* @description
|
|
1246
|
+
* API 존재 여부만 동기적으로 확인합니다.
|
|
1247
|
+
* Safari Private 모드 등에서의 실제 사용 가능 여부는 IndexedDBAdapter.init()에서 확인됩니다.
|
|
1248
|
+
*
|
|
1249
|
+
* @private
|
|
1250
|
+
* @returns {boolean} API 존재 여부
|
|
1251
|
+
*/
|
|
1252
|
+
static isIndexedDBAvailable() {
|
|
1253
|
+
try {
|
|
1254
|
+
// 기본 API 존재 확인만 수행 (동기)
|
|
1255
|
+
// 실제 사용 가능 여부(Safari Private 모드 등)는
|
|
1256
|
+
// IndexedDBAdapter.init()에서 비동기로 확인됨
|
|
1257
|
+
return typeof window !== 'undefined' && !!window.indexedDB;
|
|
899
1258
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
metrics.domProcessing = timing.domComplete - timing.domLoading;
|
|
1259
|
+
catch {
|
|
1260
|
+
return false;
|
|
903
1261
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* IndexedDB 사용 가능 여부 확인 (비동기)
|
|
1265
|
+
*
|
|
1266
|
+
* @description
|
|
1267
|
+
* 실제로 IndexedDB를 열어서 사용 가능 여부를 확인합니다.
|
|
1268
|
+
* Safari Private 모드 등에서의 제한도 감지합니다.
|
|
1269
|
+
*
|
|
1270
|
+
* @returns {Promise<boolean>} 사용 가능 여부
|
|
1271
|
+
*/
|
|
1272
|
+
static async isIndexedDBAvailableAsync() {
|
|
1273
|
+
if (typeof window === 'undefined' || !window.indexedDB) {
|
|
1274
|
+
return false;
|
|
907
1275
|
}
|
|
908
|
-
return
|
|
1276
|
+
return new Promise((resolve) => {
|
|
1277
|
+
try {
|
|
1278
|
+
const request = indexedDB.open('vircle_availability_test');
|
|
1279
|
+
request.onsuccess = () => {
|
|
1280
|
+
request.result.close();
|
|
1281
|
+
indexedDB.deleteDatabase('vircle_availability_test');
|
|
1282
|
+
resolve(true);
|
|
1283
|
+
};
|
|
1284
|
+
request.onerror = () => {
|
|
1285
|
+
resolve(false);
|
|
1286
|
+
};
|
|
1287
|
+
// Safari Private 모드에서 발생할 수 있는 blocked 이벤트
|
|
1288
|
+
request.onblocked = () => {
|
|
1289
|
+
resolve(false);
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
resolve(false);
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
909
1296
|
}
|
|
910
1297
|
/**
|
|
911
|
-
*
|
|
1298
|
+
* LocalStorage 사용 가능 여부 확인
|
|
1299
|
+
*
|
|
1300
|
+
* @returns {boolean} 사용 가능 여부
|
|
912
1301
|
*/
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1302
|
+
static isLocalStorageAvailable() {
|
|
1303
|
+
try {
|
|
1304
|
+
const testKey = '__vircle_test__';
|
|
1305
|
+
localStorage.setItem(testKey, 'test');
|
|
1306
|
+
localStorage.removeItem(testKey);
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return false;
|
|
916
1311
|
}
|
|
917
|
-
const resources = performance.getEntriesByType('resource');
|
|
918
|
-
return resources.map((resource) => ({
|
|
919
|
-
name: resource.name,
|
|
920
|
-
type: resource.initiatorType,
|
|
921
|
-
duration: resource.duration,
|
|
922
|
-
size: resource.transferSize || 0,
|
|
923
|
-
}));
|
|
924
1312
|
}
|
|
925
1313
|
}
|
|
1314
|
+
|
|
926
1315
|
/**
|
|
927
|
-
*
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const start = performance.now();
|
|
933
|
-
const result = await originalMethod.apply(this, args);
|
|
934
|
-
performance.now() - start;
|
|
935
|
-
return result;
|
|
936
|
-
};
|
|
937
|
-
return descriptor;
|
|
938
|
-
}
|
|
939
|
-
/**
|
|
940
|
-
* 디바운스 유틸리티
|
|
941
|
-
* @param func - 디바운스할 함수
|
|
942
|
-
* @param wait - 대기 시간 (ms)
|
|
943
|
-
* @returns 디바운스된 함수
|
|
1316
|
+
* Web Crypto API 기반 암호화 어댑터
|
|
1317
|
+
*
|
|
1318
|
+
* @description
|
|
1319
|
+
* 브라우저의 Web Crypto API를 사용하여 하이브리드 암호화(AES-256-GCM + RSA-OAEP)를 구현합니다.
|
|
1320
|
+
* 모든 암호화 작업은 브라우저의 네이티브 암호화 엔진을 사용하여 보안성과 성능을 보장합니다.
|
|
944
1321
|
*/
|
|
945
|
-
function debounce(func, wait) {
|
|
946
|
-
let timeoutId = null;
|
|
947
|
-
return function debounced(...args) {
|
|
948
|
-
if (timeoutId) {
|
|
949
|
-
clearTimeout(timeoutId);
|
|
950
|
-
}
|
|
951
|
-
timeoutId = setTimeout(() => {
|
|
952
|
-
func(...args);
|
|
953
|
-
timeoutId = null;
|
|
954
|
-
}, wait);
|
|
955
|
-
};
|
|
956
|
-
}
|
|
957
1322
|
/**
|
|
958
|
-
*
|
|
959
|
-
* @param func - 쓰로틀할 함수
|
|
960
|
-
* @param limit - 제한 시간 (ms)
|
|
961
|
-
* @returns 쓰로틀된 함수
|
|
1323
|
+
* Web Crypto API를 사용하는 ExtendedCryptoAdapter 구현
|
|
962
1324
|
*/
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1325
|
+
class WebExtendedCryptoAdapter {
|
|
1326
|
+
constructor() {
|
|
1327
|
+
this.crypto = null;
|
|
1328
|
+
this.subtle = null;
|
|
1329
|
+
this.available = false;
|
|
1330
|
+
this.initCrypto();
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Web Crypto API 초기화
|
|
1334
|
+
*/
|
|
1335
|
+
initCrypto() {
|
|
1336
|
+
try {
|
|
1337
|
+
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
|
|
1338
|
+
this.crypto = window.crypto;
|
|
1339
|
+
this.subtle = window.crypto.subtle;
|
|
1340
|
+
this.available = true;
|
|
1341
|
+
}
|
|
1342
|
+
else if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
|
|
1343
|
+
this.crypto = globalThis.crypto;
|
|
1344
|
+
this.subtle = globalThis.crypto.subtle;
|
|
1345
|
+
this.available = true;
|
|
1346
|
+
}
|
|
978
1347
|
}
|
|
979
|
-
|
|
980
|
-
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
console.warn('[Vircle] Web Crypto API를 사용할 수 없습니다:', error);
|
|
1350
|
+
this.available = false;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* UUID v4 생성
|
|
1355
|
+
*/
|
|
1356
|
+
generateUUID() {
|
|
1357
|
+
if (this.crypto && typeof this.crypto.randomUUID === 'function') {
|
|
1358
|
+
return this.crypto.randomUUID();
|
|
1359
|
+
}
|
|
1360
|
+
// Fallback: crypto.getRandomValues 사용
|
|
1361
|
+
if (this.crypto) {
|
|
1362
|
+
const bytes = new Uint8Array(16);
|
|
1363
|
+
this.crypto.getRandomValues(bytes);
|
|
1364
|
+
// Set version (4) and variant bits
|
|
1365
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
1366
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
1367
|
+
// Convert to UUID string format
|
|
1368
|
+
const hex = Array.from(bytes)
|
|
1369
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1370
|
+
.join('');
|
|
1371
|
+
return [
|
|
1372
|
+
hex.substring(0, 8),
|
|
1373
|
+
hex.substring(8, 12),
|
|
1374
|
+
hex.substring(12, 16),
|
|
1375
|
+
hex.substring(16, 20),
|
|
1376
|
+
hex.substring(20, 32),
|
|
1377
|
+
].join('-');
|
|
1378
|
+
}
|
|
1379
|
+
// Final fallback (less secure)
|
|
1380
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
1381
|
+
const r = (Math.random() * 16) | 0;
|
|
1382
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
1383
|
+
return v.toString(16);
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* HMAC-SHA256 서명 생성
|
|
1388
|
+
*/
|
|
1389
|
+
async createHmacSignature(payload, secret) {
|
|
1390
|
+
if (!this.subtle) {
|
|
1391
|
+
throw new Error('Web Crypto API is required for HMAC operations');
|
|
1392
|
+
}
|
|
1393
|
+
const encoder = new TextEncoder();
|
|
1394
|
+
const data = JSON.stringify(payload);
|
|
1395
|
+
// Import secret key
|
|
1396
|
+
const keyData = encoder.encode(secret);
|
|
1397
|
+
const key = await this.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
1398
|
+
// Sign data
|
|
1399
|
+
const signature = await this.subtle.sign('HMAC', key, encoder.encode(data));
|
|
1400
|
+
// Convert to hex string
|
|
1401
|
+
return this.arrayBufferToHex(signature);
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* 하이브리드 암호화 (AES-256-GCM + RSA-OAEP)
|
|
1405
|
+
*
|
|
1406
|
+
* @description
|
|
1407
|
+
* 1. AES-256-GCM으로 데이터 암호화
|
|
1408
|
+
* 2. RSA-OAEP로 AES 키 암호화
|
|
1409
|
+
*/
|
|
1410
|
+
async encryptPayload(data, publicKeyBase64) {
|
|
1411
|
+
if (!this.subtle || !this.crypto) {
|
|
1412
|
+
throw new Error('Web Crypto API is required for encryption operations');
|
|
1413
|
+
}
|
|
1414
|
+
let step = 'initialization';
|
|
1415
|
+
try {
|
|
1416
|
+
step = 'JSON serialization';
|
|
1417
|
+
const jsonStr = JSON.stringify(data);
|
|
1418
|
+
const encoder = new TextEncoder();
|
|
1419
|
+
const dataBytes = encoder.encode(jsonStr);
|
|
1420
|
+
// 1. Generate AES-256-GCM key
|
|
1421
|
+
step = 'AES key generation';
|
|
1422
|
+
const aesKey = await this.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
|
|
1423
|
+
// 2. Generate IV (96-bit for GCM)
|
|
1424
|
+
step = 'IV generation';
|
|
1425
|
+
const iv = this.crypto.getRandomValues(new Uint8Array(12));
|
|
1426
|
+
// 3. Encrypt data with AES-GCM
|
|
1427
|
+
step = 'AES-GCM encryption';
|
|
1428
|
+
const encryptedData = await this.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, aesKey, dataBytes);
|
|
1429
|
+
// AES-GCM returns ciphertext + authTag concatenated
|
|
1430
|
+
// authTag is the last 16 bytes (128 bits)
|
|
1431
|
+
const encryptedBytes = new Uint8Array(encryptedData);
|
|
1432
|
+
const ciphertext = encryptedBytes.slice(0, -16);
|
|
1433
|
+
const authTag = encryptedBytes.slice(-16);
|
|
1434
|
+
// 4. Export AES key as raw bytes
|
|
1435
|
+
step = 'AES key export';
|
|
1436
|
+
const rawAesKey = await this.subtle.exportKey('raw', aesKey);
|
|
1437
|
+
// 5. Import RSA public key
|
|
1438
|
+
step = 'RSA public key import';
|
|
1439
|
+
const rsaPublicKey = await this.importRsaPublicKey(publicKeyBase64);
|
|
1440
|
+
// 6. Encrypt AES key with RSA-OAEP
|
|
1441
|
+
step = 'RSA-OAEP encryption';
|
|
1442
|
+
const encryptedKey = await this.subtle.encrypt({ name: 'RSA-OAEP' }, rsaPublicKey, rawAesKey);
|
|
1443
|
+
// 7. Convert to base64 for transport
|
|
1444
|
+
step = 'Base64 encoding';
|
|
1445
|
+
return {
|
|
1446
|
+
data: this.arrayBufferToBase64(ciphertext),
|
|
1447
|
+
key: this.arrayBufferToBase64(encryptedKey),
|
|
1448
|
+
iv: this.arrayBufferToBase64(iv),
|
|
1449
|
+
authTag: this.arrayBufferToBase64(authTag),
|
|
1450
|
+
metadata: {
|
|
1451
|
+
algorithm: 'AES-256-GCM',
|
|
1452
|
+
keyAlgorithm: 'RSA-OAEP',
|
|
1453
|
+
timestamp: new Date().toISOString(),
|
|
1454
|
+
},
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1459
|
+
throw new Error(`[WebExtendedCryptoAdapter] Encryption failed at step '${step}': ${errorMessage}`);
|
|
981
1460
|
}
|
|
982
|
-
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Web Crypto API 사용 가능 여부
|
|
1464
|
+
*/
|
|
1465
|
+
isAvailable() {
|
|
1466
|
+
return this.available;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* RSA 공개키 가져오기 (PEM/Base64 → CryptoKey)
|
|
1470
|
+
*/
|
|
1471
|
+
async importRsaPublicKey(publicKeyBase64) {
|
|
1472
|
+
if (!this.subtle) {
|
|
1473
|
+
throw new Error('Web Crypto API is not available');
|
|
1474
|
+
}
|
|
1475
|
+
// Clean PEM headers and whitespace
|
|
1476
|
+
const cleanedKey = publicKeyBase64
|
|
1477
|
+
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
|
1478
|
+
.replace(/-----END PUBLIC KEY-----/g, '')
|
|
1479
|
+
.replace(/-----BEGIN RSA PUBLIC KEY-----/g, '')
|
|
1480
|
+
.replace(/-----END RSA PUBLIC KEY-----/g, '')
|
|
1481
|
+
.replace(/\s/g, '');
|
|
1482
|
+
// Decode base64 to ArrayBuffer
|
|
1483
|
+
const keyData = this.base64ToArrayBuffer(cleanedKey);
|
|
1484
|
+
// Import as SPKI format
|
|
1485
|
+
return await this.subtle.importKey('spki', keyData, {
|
|
1486
|
+
name: 'RSA-OAEP',
|
|
1487
|
+
hash: 'SHA-256',
|
|
1488
|
+
}, false, ['encrypt']);
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* ArrayBuffer를 Base64 문자열로 변환
|
|
1492
|
+
*/
|
|
1493
|
+
arrayBufferToBase64(buffer) {
|
|
1494
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
1495
|
+
let binary = '';
|
|
1496
|
+
const chunkSize = 0x8000; // 32KB chunks
|
|
1497
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
1498
|
+
const chunk = bytes.slice(i, Math.min(i + chunkSize, bytes.length));
|
|
1499
|
+
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
1500
|
+
}
|
|
1501
|
+
return btoa(binary);
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Base64 문자열을 ArrayBuffer로 변환
|
|
1505
|
+
*/
|
|
1506
|
+
base64ToArrayBuffer(base64) {
|
|
1507
|
+
const binary = atob(base64);
|
|
1508
|
+
const bytes = new Uint8Array(binary.length);
|
|
1509
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1510
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1511
|
+
}
|
|
1512
|
+
return bytes.buffer;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* ArrayBuffer를 Hex 문자열로 변환
|
|
1516
|
+
*/
|
|
1517
|
+
arrayBufferToHex(buffer) {
|
|
1518
|
+
const bytes = new Uint8Array(buffer);
|
|
1519
|
+
return Array.from(bytes)
|
|
1520
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1521
|
+
.join('');
|
|
1522
|
+
}
|
|
983
1523
|
}
|
|
984
1524
|
|
|
985
1525
|
/**
|
|
@@ -998,27 +1538,38 @@ function throttle(func, limit) {
|
|
|
998
1538
|
*/
|
|
999
1539
|
class VircleWeb extends sdkCoreTs.VircleCore {
|
|
1000
1540
|
constructor(config, options = {}) {
|
|
1001
|
-
// 웹 환경용 스토리지 어댑터 설정
|
|
1002
|
-
const storageAdapter =
|
|
1541
|
+
// 웹 환경용 스토리지 어댑터 설정 - 성능 최적화를 위해 StorageFactory 사용
|
|
1542
|
+
const storageAdapter = StorageFactory.create(config.storagePrefix || 'vircle_', config.storageType || 'auto');
|
|
1543
|
+
// 암호화 어댑터 초기화
|
|
1544
|
+
let cryptoAdapter;
|
|
1545
|
+
if (config.enableEncryption || options.enableEncryption) {
|
|
1546
|
+
cryptoAdapter = new WebExtendedCryptoAdapter();
|
|
1547
|
+
if (cryptoAdapter.isAvailable()) ;
|
|
1548
|
+
else {
|
|
1549
|
+
console.warn('[Vircle] Web Crypto API를 사용할 수 없습니다. 암호화를 비활성화합니다.');
|
|
1550
|
+
cryptoAdapter = undefined;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1003
1553
|
const coreOptions = {
|
|
1004
1554
|
...options,
|
|
1005
1555
|
storageAdapter,
|
|
1556
|
+
cryptoAdapter,
|
|
1557
|
+
enableEncryption: cryptoAdapter?.isAvailable() ?? false,
|
|
1006
1558
|
};
|
|
1007
1559
|
super(config, coreOptions);
|
|
1008
1560
|
this.pageViewTracked = false;
|
|
1561
|
+
this.lastActivityTime = Date.now();
|
|
1009
1562
|
this.webConfig = config;
|
|
1010
1563
|
this.webOptions = {
|
|
1011
1564
|
flushOnUnload: true,
|
|
1012
1565
|
contextCacheTime: 300000, // 5분
|
|
1013
1566
|
idleScheduler: new sdkCoreTs.WebIdleScheduler(),
|
|
1014
|
-
enablePerformance: true,
|
|
1015
1567
|
...options,
|
|
1016
1568
|
};
|
|
1017
1569
|
// 웹 환경 전용 컴포넌트 초기화
|
|
1018
1570
|
this.contextCollector = new WebContextCollector();
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1571
|
+
// 세션 타임아웃 설정 (기본 30분)
|
|
1572
|
+
this.sessionTimeout = config.sessionTimeout ?? 30 * 60 * 1000;
|
|
1022
1573
|
}
|
|
1023
1574
|
/**
|
|
1024
1575
|
* SDK 초기화 및 자동 추적 설정
|
|
@@ -1054,16 +1605,22 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1054
1605
|
await this.trackPageView();
|
|
1055
1606
|
this.pageViewTracked = true;
|
|
1056
1607
|
}
|
|
1057
|
-
// 성능 메트릭 수집 시작
|
|
1058
|
-
if (this.performanceTracker) {
|
|
1059
|
-
this.performanceTracker.startTracking();
|
|
1060
|
-
}
|
|
1061
1608
|
}
|
|
1062
1609
|
catch (error) {
|
|
1063
1610
|
console.error('[Vircle] 초기화 실패:', error);
|
|
1064
1611
|
throw error;
|
|
1065
1612
|
}
|
|
1066
1613
|
}
|
|
1614
|
+
/**
|
|
1615
|
+
* 사용자 식별 (컨텍스트 캐시 무효화 포함)
|
|
1616
|
+
*
|
|
1617
|
+
* @description
|
|
1618
|
+
* 사용자를 식별하고 컨텍스트 캐시를 무효화하여 이후 이벤트에 정확한 컨텍스트가 포함되도록 합니다.
|
|
1619
|
+
*/
|
|
1620
|
+
async identify(userId, traits) {
|
|
1621
|
+
this.contextCache.invalidate();
|
|
1622
|
+
return super.identify(userId, traits);
|
|
1623
|
+
}
|
|
1067
1624
|
/**
|
|
1068
1625
|
* 페이지뷰 추적
|
|
1069
1626
|
*
|
|
@@ -1095,10 +1652,11 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1095
1652
|
}
|
|
1096
1653
|
/**
|
|
1097
1654
|
* 웹 환경에 최적화된 이벤트 추적
|
|
1098
|
-
*
|
|
1655
|
+
* 이제 코어의 TaskScheduler를 사용하여 requestIdleCallback 기반의
|
|
1099
1656
|
* 일관된 성능 최적화 구현
|
|
1100
1657
|
*/
|
|
1101
1658
|
async track(name, properties, context) {
|
|
1659
|
+
this.lastActivityTime = Date.now();
|
|
1102
1660
|
return new Promise((resolve) => {
|
|
1103
1661
|
this.taskScheduler.enqueue(async () => {
|
|
1104
1662
|
try {
|
|
@@ -1127,7 +1685,7 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1127
1685
|
* @private
|
|
1128
1686
|
* @returns {Promise<EventContext>} 이벤트 컨텍스트
|
|
1129
1687
|
*
|
|
1130
|
-
*
|
|
1688
|
+
* 컨텍스트 수집은 DOM 접근이 많아 비용이 높으므로
|
|
1131
1689
|
* 캐싱을 통해 성능을 최적화. 중복 수집 방지를 위한 Promise 재사용 구현
|
|
1132
1690
|
*/
|
|
1133
1691
|
async getCachedContext() {
|
|
@@ -1143,29 +1701,22 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1143
1701
|
* @private
|
|
1144
1702
|
*/
|
|
1145
1703
|
setupAutoTracking() {
|
|
1146
|
-
// 페이지 언로드 시
|
|
1704
|
+
// 페이지 언로드 시 sendBeacon으로 전송
|
|
1147
1705
|
if (this.webOptions.flushOnUnload) {
|
|
1148
|
-
this.unloadHandler =
|
|
1149
|
-
|
|
1150
|
-
// 대기 중인 idle 작업들 먼저 처리
|
|
1151
|
-
// Flush any pending tasks
|
|
1152
|
-
await this.flush();
|
|
1153
|
-
// 이벤트 플러시
|
|
1154
|
-
await this.flush();
|
|
1155
|
-
}
|
|
1156
|
-
catch (error) {
|
|
1157
|
-
console.warn('[Vircle] 언로드 시 플러시 실패:', error);
|
|
1158
|
-
}
|
|
1706
|
+
this.unloadHandler = () => {
|
|
1707
|
+
this.transportService.flushViaBeacon();
|
|
1159
1708
|
};
|
|
1160
1709
|
window.addEventListener('beforeunload', this.unloadHandler);
|
|
1161
|
-
// visibilitychange
|
|
1162
|
-
|
|
1710
|
+
// visibilitychange 이벤트 처리 (sendBeacon + 세션 타임아웃)
|
|
1711
|
+
this.visibilityChangeHandler = () => {
|
|
1163
1712
|
if (document.visibilityState === 'hidden') {
|
|
1164
|
-
this.
|
|
1165
|
-
console.warn('[Vircle] 백그라운드 전환 시 플러시 실패:', error);
|
|
1166
|
-
});
|
|
1713
|
+
this.transportService.flushViaBeacon();
|
|
1167
1714
|
}
|
|
1168
|
-
|
|
1715
|
+
else if (document.visibilityState === 'visible') {
|
|
1716
|
+
this.checkSessionTimeout();
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
|
|
1169
1720
|
}
|
|
1170
1721
|
// 에러 추적
|
|
1171
1722
|
if (this.webConfig.trackErrors) {
|
|
@@ -1275,16 +1826,23 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1275
1826
|
* @private
|
|
1276
1827
|
*/
|
|
1277
1828
|
setupSPATracking() {
|
|
1829
|
+
// 원래 함수 저장
|
|
1830
|
+
this.originalPushState = history.pushState;
|
|
1831
|
+
this.originalReplaceState = history.replaceState;
|
|
1278
1832
|
// pushState/replaceState 래핑
|
|
1279
|
-
const originalPushState = history.pushState;
|
|
1280
|
-
const originalReplaceState = history.replaceState;
|
|
1281
1833
|
history.pushState = (...args) => {
|
|
1282
|
-
originalPushState.apply(history, args);
|
|
1283
|
-
|
|
1834
|
+
this.originalPushState.apply(history, args);
|
|
1835
|
+
try {
|
|
1836
|
+
this.handleRouteChange();
|
|
1837
|
+
}
|
|
1838
|
+
catch (_) { /* SDK 오류가 호스트 앱 라우팅에 영향 주지 않도록 */ }
|
|
1284
1839
|
};
|
|
1285
1840
|
history.replaceState = (...args) => {
|
|
1286
|
-
originalReplaceState.apply(history, args);
|
|
1287
|
-
|
|
1841
|
+
this.originalReplaceState.apply(history, args);
|
|
1842
|
+
try {
|
|
1843
|
+
this.handleRouteChange();
|
|
1844
|
+
}
|
|
1845
|
+
catch (_) { /* SDK 오류가 호스트 앱 라우팅에 영향 주지 않도록 */ }
|
|
1288
1846
|
};
|
|
1289
1847
|
// popstate 이벤트 리스너
|
|
1290
1848
|
this.popstateHandler = () => {
|
|
@@ -1311,6 +1869,34 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1311
1869
|
});
|
|
1312
1870
|
}
|
|
1313
1871
|
}
|
|
1872
|
+
/**
|
|
1873
|
+
* 세션 타임아웃 체크
|
|
1874
|
+
*
|
|
1875
|
+
* @description
|
|
1876
|
+
* 마지막 활동 시간으로부터 세션 타임아웃이 경과했는지 확인합니다.
|
|
1877
|
+
* 만료 시 새 세션을 생성하고 session_start 이벤트를 추적합니다.
|
|
1878
|
+
*
|
|
1879
|
+
* @private
|
|
1880
|
+
*/
|
|
1881
|
+
checkSessionTimeout() {
|
|
1882
|
+
const now = Date.now();
|
|
1883
|
+
const elapsed = now - this.lastActivityTime;
|
|
1884
|
+
if (elapsed >= this.sessionTimeout) {
|
|
1885
|
+
const newSessionId = `sess_${now}_${Math.random().toString(36).substr(2, 9)}`;
|
|
1886
|
+
this.setSessionId(newSessionId);
|
|
1887
|
+
this.lastActivityTime = now;
|
|
1888
|
+
this.contextCache.invalidate();
|
|
1889
|
+
this.track('session_start', {
|
|
1890
|
+
reason: 'timeout',
|
|
1891
|
+
previous_session_duration: elapsed,
|
|
1892
|
+
}).catch((error) => {
|
|
1893
|
+
console.warn('[Vircle] 세션 시작 추적 실패:', error);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
this.lastActivityTime = now;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1314
1900
|
/**
|
|
1315
1901
|
* 요소가 추적 대상인지 확인
|
|
1316
1902
|
*
|
|
@@ -1372,8 +1958,8 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1372
1958
|
this.track('element_data_collected', {
|
|
1373
1959
|
...properties,
|
|
1374
1960
|
data_attributes: dataAttributes,
|
|
1375
|
-
}).catch((
|
|
1376
|
-
|
|
1961
|
+
}).catch(() => {
|
|
1962
|
+
// data attributes 수집 실패는 무시
|
|
1377
1963
|
});
|
|
1378
1964
|
}
|
|
1379
1965
|
});
|
|
@@ -1415,12 +2001,20 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1415
2001
|
if (this.submitHandler) {
|
|
1416
2002
|
document.removeEventListener('submit', this.submitHandler, true);
|
|
1417
2003
|
}
|
|
2004
|
+
if (this.visibilityChangeHandler) {
|
|
2005
|
+
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
|
|
2006
|
+
}
|
|
1418
2007
|
if (this.popstateHandler) {
|
|
1419
2008
|
window.removeEventListener('popstate', this.popstateHandler);
|
|
1420
2009
|
}
|
|
1421
|
-
//
|
|
1422
|
-
if (this.
|
|
1423
|
-
this.
|
|
2010
|
+
// History API 원본 복원
|
|
2011
|
+
if (this.originalPushState) {
|
|
2012
|
+
history.pushState = this.originalPushState;
|
|
2013
|
+
this.originalPushState = undefined;
|
|
2014
|
+
}
|
|
2015
|
+
if (this.originalReplaceState) {
|
|
2016
|
+
history.replaceState = this.originalReplaceState;
|
|
2017
|
+
this.originalReplaceState = undefined;
|
|
1424
2018
|
}
|
|
1425
2019
|
// Idle 작업 정리
|
|
1426
2020
|
// Flush pending tasks
|
|
@@ -1472,7 +2066,9 @@ class VircleWeb extends sdkCoreTs.VircleCore {
|
|
|
1472
2066
|
}
|
|
1473
2067
|
}
|
|
1474
2068
|
|
|
2069
|
+
exports.IndexedDBAdapter = IndexedDBAdapter;
|
|
1475
2070
|
exports.LocalStorageAdapter = LocalStorageAdapter;
|
|
2071
|
+
exports.StorageFactory = StorageFactory;
|
|
1476
2072
|
exports.VircleBrowserCompatibilityError = VircleBrowserCompatibilityError;
|
|
1477
2073
|
exports.VircleConfigError = VircleConfigError;
|
|
1478
2074
|
exports.VircleInitializationError = VircleInitializationError;
|
|
@@ -1480,9 +2076,6 @@ exports.VircleStorageError = VircleStorageError;
|
|
|
1480
2076
|
exports.VircleWeb = VircleWeb;
|
|
1481
2077
|
exports.VircleWebError = VircleWebError;
|
|
1482
2078
|
exports.WebContextCollector = WebContextCollector;
|
|
1483
|
-
exports.
|
|
1484
|
-
exports.debounce = debounce;
|
|
2079
|
+
exports.WebExtendedCryptoAdapter = WebExtendedCryptoAdapter;
|
|
1485
2080
|
exports.default = VircleWeb;
|
|
1486
|
-
exports.measurePerformance = measurePerformance;
|
|
1487
|
-
exports.throttle = throttle;
|
|
1488
2081
|
//# sourceMappingURL=index.js.map
|