@idealyst/storage 1.2.106 → 1.2.108
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/package.json +10 -2
- package/src/createSecureStorage.native.ts +11 -0
- package/src/createSecureStorage.ts +14 -0
- package/src/createSecureStorage.web.ts +12 -0
- package/src/index.native.ts +2 -0
- package/src/index.ts +1 -0
- package/src/index.web.ts +2 -0
- package/src/secure-storage.native.ts +136 -0
- package/src/secure-storage.web.ts +213 -0
- package/src/types.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/storage",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.108",
|
|
4
4
|
"description": "Cross-platform storage abstraction for React and React Native",
|
|
5
5
|
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/storage#readme",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": ">=16.8.0",
|
|
42
42
|
"react-native": ">=0.60.0",
|
|
43
|
+
"react-native-keychain": ">=9.0.0",
|
|
43
44
|
"react-native-mmkv": ">=4.0.0",
|
|
44
45
|
"react-native-nitro-modules": ">=0.20.0"
|
|
45
46
|
},
|
|
@@ -47,6 +48,9 @@
|
|
|
47
48
|
"react-native": {
|
|
48
49
|
"optional": true
|
|
49
50
|
},
|
|
51
|
+
"react-native-keychain": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
50
54
|
"react-native-mmkv": {
|
|
51
55
|
"optional": true
|
|
52
56
|
},
|
|
@@ -56,6 +60,7 @@
|
|
|
56
60
|
},
|
|
57
61
|
"devDependencies": {
|
|
58
62
|
"@types/react": "^19.1.0",
|
|
63
|
+
"react-native-keychain": "^9.2.0",
|
|
59
64
|
"react-native-mmkv": "^4.1.1",
|
|
60
65
|
"react-native-nitro-modules": "^0.32.1",
|
|
61
66
|
"typescript": "^5.0.0"
|
|
@@ -70,6 +75,9 @@
|
|
|
70
75
|
"storage",
|
|
71
76
|
"cross-platform",
|
|
72
77
|
"localStorage",
|
|
73
|
-
"MMKV"
|
|
78
|
+
"MMKV",
|
|
79
|
+
"secure-storage",
|
|
80
|
+
"keychain",
|
|
81
|
+
"encryption"
|
|
74
82
|
]
|
|
75
83
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import BaseStorage from './storage';
|
|
2
|
+
import NativeSecureStorage from './secure-storage.native';
|
|
3
|
+
import type { SecureStorageOptions } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a secure storage instance using encrypted MMKV backed by the OS Keychain.
|
|
7
|
+
* The MMKV encryption key is stored in the iOS Keychain / Android Keystore.
|
|
8
|
+
*/
|
|
9
|
+
export function createSecureStorage(options?: SecureStorageOptions): BaseStorage {
|
|
10
|
+
return new BaseStorage(new NativeSecureStorage(options));
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import BaseStorage from './storage';
|
|
2
|
+
import type { SecureStorageOptions } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a secure storage instance with encrypted data at rest.
|
|
6
|
+
*
|
|
7
|
+
* Platform stub — replaced by `.web.ts` and `.native.ts` at build time.
|
|
8
|
+
*/
|
|
9
|
+
export function createSecureStorage(_options?: SecureStorageOptions): BaseStorage {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'createSecureStorage is not available on this platform. ' +
|
|
12
|
+
'Import from a platform-specific entry point.'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import BaseStorage from './storage';
|
|
2
|
+
import WebSecureStorage from './secure-storage.web';
|
|
3
|
+
import type { SecureStorageOptions } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a secure storage instance using AES-GCM encryption via the Web Crypto API.
|
|
7
|
+
* Encryption keys are stored as non-extractable CryptoKeys in IndexedDB.
|
|
8
|
+
* Encrypted values are persisted in localStorage.
|
|
9
|
+
*/
|
|
10
|
+
export function createSecureStorage(options?: SecureStorageOptions): BaseStorage {
|
|
11
|
+
return new BaseStorage(new WebSecureStorage(options));
|
|
12
|
+
}
|
package/src/index.native.ts
CHANGED
|
@@ -6,4 +6,6 @@ const storage = new BaseStorage(new NativeStorage());
|
|
|
6
6
|
export default storage;
|
|
7
7
|
// Export instance as both `storage` and `Storage` for convenience
|
|
8
8
|
export { storage, storage as Storage, BaseStorage, NativeStorage };
|
|
9
|
+
export { createSecureStorage } from './createSecureStorage.native';
|
|
10
|
+
export { default as NativeSecureStorage } from './secure-storage.native';
|
|
9
11
|
export * from './types';
|
package/src/index.ts
CHANGED
package/src/index.web.ts
CHANGED
|
@@ -6,4 +6,6 @@ const storage = new BaseStorage(new WebStorage());
|
|
|
6
6
|
export default storage;
|
|
7
7
|
// Export instance as both `storage` and `Storage` for convenience
|
|
8
8
|
export { storage, storage as Storage, BaseStorage, WebStorage };
|
|
9
|
+
export { createSecureStorage } from './createSecureStorage.web';
|
|
10
|
+
export { default as WebSecureStorage } from './secure-storage.web';
|
|
9
11
|
export * from './types';
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createMMKV } from 'react-native-mmkv';
|
|
2
|
+
import * as Keychain from 'react-native-keychain';
|
|
3
|
+
import { IStorage, SecureStorageOptions } from './types';
|
|
4
|
+
|
|
5
|
+
const KEY_USERNAME = '__mmkv_encryption_key__';
|
|
6
|
+
|
|
7
|
+
class NativeSecureStorage implements IStorage {
|
|
8
|
+
private prefix: string;
|
|
9
|
+
private mmkv: ReturnType<typeof createMMKV> | null = null;
|
|
10
|
+
private initPromise: Promise<void> | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(options: SecureStorageOptions = {}) {
|
|
13
|
+
this.prefix = options.prefix ?? 'secure';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private async ensureInitialized(): Promise<ReturnType<typeof createMMKV>> {
|
|
17
|
+
if (this.mmkv) return this.mmkv;
|
|
18
|
+
if (!this.initPromise) {
|
|
19
|
+
this.initPromise = this._init();
|
|
20
|
+
}
|
|
21
|
+
await this.initPromise;
|
|
22
|
+
return this.mmkv!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async _init(): Promise<void> {
|
|
26
|
+
const service = `${this.prefix}_keychain`;
|
|
27
|
+
|
|
28
|
+
let encryptionKey: string;
|
|
29
|
+
const credentials = await Keychain.getGenericPassword({ service });
|
|
30
|
+
|
|
31
|
+
if (credentials && credentials.password) {
|
|
32
|
+
encryptionKey = credentials.password;
|
|
33
|
+
} else {
|
|
34
|
+
// Generate a random 16-byte key (MMKV max encryptionKey length is 16 bytes)
|
|
35
|
+
const randomBytes = new Uint8Array(16);
|
|
36
|
+
globalThis.crypto.getRandomValues(randomBytes);
|
|
37
|
+
encryptionKey = String.fromCharCode(...randomBytes);
|
|
38
|
+
|
|
39
|
+
await Keychain.setGenericPassword(KEY_USERNAME, encryptionKey, {
|
|
40
|
+
service,
|
|
41
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.mmkv = createMMKV({
|
|
46
|
+
id: `${this.prefix}_secure_mmkv`,
|
|
47
|
+
encryptionKey,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getItem(key: string): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
const storage = await this.ensureInitialized();
|
|
54
|
+
return storage.getString(key) || null;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Error getting item from secure storage:', error);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
const storage = await this.ensureInitialized();
|
|
64
|
+
storage.set(key, value);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Error setting item in secure storage:', error);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async removeItem(key: string): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
const storage = await this.ensureInitialized();
|
|
74
|
+
storage.remove(key);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('Error removing item from secure storage:', error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async clear(): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const storage = await this.ensureInitialized();
|
|
84
|
+
storage.clearAll();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Error clearing secure storage:', error);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getAllKeys(): Promise<string[]> {
|
|
92
|
+
try {
|
|
93
|
+
const storage = await this.ensureInitialized();
|
|
94
|
+
return storage.getAllKeys();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Error getting all keys from secure storage:', error);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async multiGet(keys: string[]): Promise<Array<[string, string | null]>> {
|
|
102
|
+
try {
|
|
103
|
+
const storage = await this.ensureInitialized();
|
|
104
|
+
return keys.map(key => [key, storage.getString(key) || null]);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('Error in multiGet from secure storage:', error);
|
|
107
|
+
return keys.map(key => [key, null]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async multiSet(keyValuePairs: Array<[string, string]>): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
const storage = await this.ensureInitialized();
|
|
114
|
+
keyValuePairs.forEach(([key, value]) => {
|
|
115
|
+
storage.set(key, value);
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Error in multiSet to secure storage:', error);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async multiRemove(keys: string[]): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
const storage = await this.ensureInitialized();
|
|
126
|
+
keys.forEach(key => {
|
|
127
|
+
storage.remove(key);
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('Error in multiRemove from secure storage:', error);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default NativeSecureStorage;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { IStorage, SecureStorageOptions } from './types';
|
|
2
|
+
|
|
3
|
+
const DB_NAME = 'idealyst_secure_storage';
|
|
4
|
+
const DB_VERSION = 1;
|
|
5
|
+
const STORE_NAME = 'crypto_keys';
|
|
6
|
+
const IV_LENGTH = 12;
|
|
7
|
+
|
|
8
|
+
class WebSecureStorage implements IStorage {
|
|
9
|
+
private prefix: string;
|
|
10
|
+
private cryptoKey: CryptoKey | null = null;
|
|
11
|
+
private initPromise: Promise<void> | null = null;
|
|
12
|
+
|
|
13
|
+
constructor(options: SecureStorageOptions = {}) {
|
|
14
|
+
this.prefix = options.prefix ?? 'secure';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private async ensureInitialized(): Promise<CryptoKey> {
|
|
18
|
+
if (this.cryptoKey) return this.cryptoKey;
|
|
19
|
+
if (!this.initPromise) {
|
|
20
|
+
this.initPromise = this._init();
|
|
21
|
+
}
|
|
22
|
+
await this.initPromise;
|
|
23
|
+
return this.cryptoKey!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private async _init(): Promise<void> {
|
|
27
|
+
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Secure storage requires the Web Crypto API (crypto.subtle). ' +
|
|
30
|
+
'Ensure you are running in a secure context (HTTPS).'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const db = await this.openDB();
|
|
35
|
+
const keyName = `${this.prefix}_aes_key`;
|
|
36
|
+
|
|
37
|
+
const existingKey = await this.getKeyFromDB(db, keyName);
|
|
38
|
+
if (existingKey) {
|
|
39
|
+
this.cryptoKey = existingKey;
|
|
40
|
+
db.close();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.cryptoKey = await crypto.subtle.generateKey(
|
|
45
|
+
{ name: 'AES-GCM', length: 256 },
|
|
46
|
+
false,
|
|
47
|
+
['encrypt', 'decrypt']
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await this.putKeyInDB(db, keyName, this.cryptoKey);
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async encrypt(plaintext: string): Promise<string> {
|
|
55
|
+
const key = await this.ensureInitialized();
|
|
56
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
57
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
58
|
+
|
|
59
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
60
|
+
{ name: 'AES-GCM', iv },
|
|
61
|
+
key,
|
|
62
|
+
encoded
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
66
|
+
combined.set(iv);
|
|
67
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
68
|
+
|
|
69
|
+
return btoa(String.fromCharCode(...combined));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async decrypt(stored: string): Promise<string> {
|
|
73
|
+
const key = await this.ensureInitialized();
|
|
74
|
+
const combined = Uint8Array.from(atob(stored), c => c.charCodeAt(0));
|
|
75
|
+
|
|
76
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
77
|
+
const ciphertext = combined.slice(IV_LENGTH);
|
|
78
|
+
|
|
79
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
80
|
+
{ name: 'AES-GCM', iv },
|
|
81
|
+
key,
|
|
82
|
+
ciphertext
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return new TextDecoder().decode(decrypted);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private storageKey(key: string): string {
|
|
89
|
+
return `__secure_${this.prefix}_${key}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private storageKeyPrefix(): string {
|
|
93
|
+
return `__secure_${this.prefix}_`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getItem(key: string): Promise<string | null> {
|
|
97
|
+
try {
|
|
98
|
+
const stored = localStorage.getItem(this.storageKey(key));
|
|
99
|
+
if (stored === null) return null;
|
|
100
|
+
return await this.decrypt(stored);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Error getting item from secure storage:', error);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
108
|
+
try {
|
|
109
|
+
const encrypted = await this.encrypt(value);
|
|
110
|
+
localStorage.setItem(this.storageKey(key), encrypted);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Error setting item in secure storage:', error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async removeItem(key: string): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
localStorage.removeItem(this.storageKey(key));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('Error removing item from secure storage:', error);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async clear(): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
const keys = await this.getAllKeys();
|
|
129
|
+
keys.forEach(key => localStorage.removeItem(this.storageKey(key)));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Error clearing secure storage:', error);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async getAllKeys(): Promise<string[]> {
|
|
137
|
+
try {
|
|
138
|
+
const prefix = this.storageKeyPrefix();
|
|
139
|
+
const keys: string[] = [];
|
|
140
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
141
|
+
const fullKey = localStorage.key(i);
|
|
142
|
+
if (fullKey?.startsWith(prefix)) {
|
|
143
|
+
keys.push(fullKey.slice(prefix.length));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return keys;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error('Error getting all keys from secure storage:', error);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async multiGet(keys: string[]): Promise<Array<[string, string | null]>> {
|
|
154
|
+
return Promise.all(
|
|
155
|
+
keys.map(async (key) => {
|
|
156
|
+
const value = await this.getItem(key);
|
|
157
|
+
return [key, value] as [string, string | null];
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async multiSet(keyValuePairs: Array<[string, string]>): Promise<void> {
|
|
163
|
+
for (const [key, value] of keyValuePairs) {
|
|
164
|
+
await this.setItem(key, value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async multiRemove(keys: string[]): Promise<void> {
|
|
169
|
+
for (const key of keys) {
|
|
170
|
+
await this.removeItem(key);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private openDB(): Promise<IDBDatabase> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
177
|
+
|
|
178
|
+
request.onupgradeneeded = () => {
|
|
179
|
+
const db = request.result;
|
|
180
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
181
|
+
db.createObjectStore(STORE_NAME);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
request.onsuccess = () => resolve(request.result);
|
|
186
|
+
request.onerror = () => reject(request.error);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private getKeyFromDB(db: IDBDatabase, keyName: string): Promise<CryptoKey | null> {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
193
|
+
const store = tx.objectStore(STORE_NAME);
|
|
194
|
+
const request = store.get(keyName);
|
|
195
|
+
|
|
196
|
+
request.onsuccess = () => resolve(request.result ?? null);
|
|
197
|
+
request.onerror = () => reject(request.error);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private putKeyInDB(db: IDBDatabase, keyName: string, key: CryptoKey): Promise<void> {
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
204
|
+
const store = tx.objectStore(STORE_NAME);
|
|
205
|
+
const request = store.put(key, keyName);
|
|
206
|
+
|
|
207
|
+
request.onsuccess = () => resolve();
|
|
208
|
+
request.onerror = () => reject(request.error);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default WebSecureStorage;
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export interface SecureStorageOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Namespace prefix for storage keys.
|
|
4
|
+
* - Native: used as the Keychain service name and MMKV instance ID.
|
|
5
|
+
* - Web: used as prefix for localStorage keys and IndexedDB key name.
|
|
6
|
+
* @default 'secure'
|
|
7
|
+
*/
|
|
8
|
+
prefix?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
export interface IStorage {
|
|
2
12
|
getItem: (key: string) => Promise<string | null>;
|
|
3
13
|
setItem: (key: string, value: string) => Promise<void>;
|