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