@dynlabs/react-native-immutable-file-cache 1.0.0-alpha.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/LICENSE +21 -0
- package/README.md +415 -0
- package/lib/commonjs/adapters/memoryAdapter.js +266 -0
- package/lib/commonjs/adapters/memoryAdapter.js.map +1 -0
- package/lib/commonjs/adapters/rnfsAdapter.js +259 -0
- package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -0
- package/lib/commonjs/adapters/webAdapter.js +432 -0
- package/lib/commonjs/adapters/webAdapter.js.map +1 -0
- package/lib/commonjs/core/adapter.js +2 -0
- package/lib/commonjs/core/adapter.js.map +1 -0
- package/lib/commonjs/core/cacheEngine.js +578 -0
- package/lib/commonjs/core/cacheEngine.js.map +1 -0
- package/lib/commonjs/core/errors.js +83 -0
- package/lib/commonjs/core/errors.js.map +1 -0
- package/lib/commonjs/core/hash.js +83 -0
- package/lib/commonjs/core/hash.js.map +1 -0
- package/lib/commonjs/core/indexStore.js +175 -0
- package/lib/commonjs/core/indexStore.js.map +1 -0
- package/lib/commonjs/core/mutex.js +143 -0
- package/lib/commonjs/core/mutex.js.map +1 -0
- package/lib/commonjs/core/prune.js +127 -0
- package/lib/commonjs/core/prune.js.map +1 -0
- package/lib/commonjs/core/types.js +6 -0
- package/lib/commonjs/core/types.js.map +1 -0
- package/lib/commonjs/factory.js +56 -0
- package/lib/commonjs/factory.js.map +1 -0
- package/lib/commonjs/index.js +110 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/index.native.js +74 -0
- package/lib/commonjs/index.native.js.map +1 -0
- package/lib/commonjs/index.web.js +75 -0
- package/lib/commonjs/index.web.js.map +1 -0
- package/lib/commonjs/types/react-native-fs.d.js +2 -0
- package/lib/commonjs/types/react-native-fs.d.js.map +1 -0
- package/lib/module/adapters/memoryAdapter.js +261 -0
- package/lib/module/adapters/memoryAdapter.js.map +1 -0
- package/lib/module/adapters/rnfsAdapter.js +251 -0
- package/lib/module/adapters/rnfsAdapter.js.map +1 -0
- package/lib/module/adapters/webAdapter.js +426 -0
- package/lib/module/adapters/webAdapter.js.map +1 -0
- package/lib/module/core/adapter.js +2 -0
- package/lib/module/core/adapter.js.map +1 -0
- package/lib/module/core/cacheEngine.js +571 -0
- package/lib/module/core/cacheEngine.js.map +1 -0
- package/lib/module/core/errors.js +71 -0
- package/lib/module/core/errors.js.map +1 -0
- package/lib/module/core/hash.js +76 -0
- package/lib/module/core/hash.js.map +1 -0
- package/lib/module/core/indexStore.js +168 -0
- package/lib/module/core/indexStore.js.map +1 -0
- package/lib/module/core/mutex.js +135 -0
- package/lib/module/core/mutex.js.map +1 -0
- package/lib/module/core/prune.js +116 -0
- package/lib/module/core/prune.js.map +1 -0
- package/lib/module/core/types.js +2 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/factory.js +49 -0
- package/lib/module/factory.js.map +1 -0
- package/lib/module/index.js +41 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/index.native.js +54 -0
- package/lib/module/index.native.js.map +1 -0
- package/lib/module/index.web.js +55 -0
- package/lib/module/index.web.js.map +1 -0
- package/lib/module/types/react-native-fs.d.js +2 -0
- package/lib/module/types/react-native-fs.d.js.map +1 -0
- package/lib/typescript/src/adapters/memoryAdapter.d.ts +23 -0
- package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -0
- package/lib/typescript/src/adapters/rnfsAdapter.d.ts +18 -0
- package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -0
- package/lib/typescript/src/adapters/webAdapter.d.ts +30 -0
- package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -0
- package/lib/typescript/src/core/adapter.d.ts +105 -0
- package/lib/typescript/src/core/adapter.d.ts.map +1 -0
- package/lib/typescript/src/core/cacheEngine.d.ts +99 -0
- package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -0
- package/lib/typescript/src/core/errors.d.ts +54 -0
- package/lib/typescript/src/core/errors.d.ts.map +1 -0
- package/lib/typescript/src/core/hash.d.ts +20 -0
- package/lib/typescript/src/core/hash.d.ts.map +1 -0
- package/lib/typescript/src/core/indexStore.d.ts +34 -0
- package/lib/typescript/src/core/indexStore.d.ts.map +1 -0
- package/lib/typescript/src/core/mutex.d.ts +49 -0
- package/lib/typescript/src/core/mutex.d.ts.map +1 -0
- package/lib/typescript/src/core/prune.d.ts +39 -0
- package/lib/typescript/src/core/prune.d.ts.map +1 -0
- package/lib/typescript/src/core/types.d.ts +109 -0
- package/lib/typescript/src/core/types.d.ts.map +1 -0
- package/lib/typescript/src/factory.d.ts +46 -0
- package/lib/typescript/src/factory.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +20 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/index.native.d.ts +37 -0
- package/lib/typescript/src/index.native.d.ts.map +1 -0
- package/lib/typescript/src/index.web.d.ts +38 -0
- package/lib/typescript/src/index.web.d.ts.map +1 -0
- package/package.json +125 -0
- package/src/adapters/memoryAdapter.ts +307 -0
- package/src/adapters/rnfsAdapter.ts +283 -0
- package/src/adapters/webAdapter.ts +480 -0
- package/src/core/adapter.ts +128 -0
- package/src/core/cacheEngine.ts +634 -0
- package/src/core/errors.ts +82 -0
- package/src/core/hash.ts +78 -0
- package/src/core/indexStore.ts +184 -0
- package/src/core/mutex.ts +134 -0
- package/src/core/prune.ts +145 -0
- package/src/core/types.ts +165 -0
- package/src/factory.ts +60 -0
- package/src/index.native.ts +58 -0
- package/src/index.ts +82 -0
- package/src/index.web.ts +59 -0
- package/src/types/react-native-fs.d.ts +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dean
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# @dynlabs/react-native-immutable-file-cache
|
|
2
|
+
|
|
3
|
+
[](https://github.com/dienp/react-native-immutable-file-cache/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@dynlabs/react-native-immutable-file-cache)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
A cross-platform immutable file cache for React Native and Web with pluggable storage adapters.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Immutable entries** - Once cached, entries cannot be overwritten
|
|
12
|
+
- **Cross-platform** - Works on iOS, Android, and Web
|
|
13
|
+
- **Pluggable adapters** - Swap storage backends without changing application code
|
|
14
|
+
- **TTL & LRU pruning** - Automatic cache management
|
|
15
|
+
- **Atomic writes** - Crash-safe file operations
|
|
16
|
+
- **TypeScript first** - Full type safety with strict types
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @dynlabs/react-native-immutable-file-cache
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### React Native (iOS/Android)
|
|
25
|
+
|
|
26
|
+
Also install the filesystem dependency:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install react-native-fs
|
|
30
|
+
cd ios && pod install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Web
|
|
34
|
+
|
|
35
|
+
No additional dependencies required - uses Cache Storage and IndexedDB.
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { createImmutableFileCache } from "@dynlabs/react-native-immutable-file-cache";
|
|
41
|
+
|
|
42
|
+
// Create cache instance
|
|
43
|
+
const cache = await createImmutableFileCache({
|
|
44
|
+
namespace: "images",
|
|
45
|
+
defaultTtlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
46
|
+
maxSizeBytes: 100 * 1024 * 1024, // 100 MB
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Cache an image from URL
|
|
50
|
+
const result = await cache.putFromUrl(
|
|
51
|
+
"avatar-123",
|
|
52
|
+
"https://example.com/avatar.jpg"
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (result.status === "created") {
|
|
56
|
+
console.log("Cached new image:", result.entry.sizeBytes, "bytes");
|
|
57
|
+
} else {
|
|
58
|
+
console.log("Image already cached");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Retrieve cached entry
|
|
62
|
+
const entry = await cache.get("avatar-123");
|
|
63
|
+
if (entry) {
|
|
64
|
+
// Use entry.uri in Image component
|
|
65
|
+
<Image source={{ uri: entry.uri }} />
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Architecture
|
|
70
|
+
|
|
71
|
+
The package uses a three-layer architecture:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
src/
|
|
75
|
+
├── core/ # Platform-agnostic cache engine
|
|
76
|
+
├── adapters/ # Platform-specific storage adapters
|
|
77
|
+
└── index.*.ts # Platform entrypoints
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Core Layer
|
|
81
|
+
|
|
82
|
+
Contains all cache logic (immutability rules, TTL, LRU pruning, indexing) without any platform-specific code. The core depends only on the `IStorageAdapter` interface.
|
|
83
|
+
|
|
84
|
+
### Adapters Layer
|
|
85
|
+
|
|
86
|
+
Platform-specific implementations of `IStorageAdapter`:
|
|
87
|
+
|
|
88
|
+
- **RNFS Adapter** - Uses `react-native-fs` for native platforms
|
|
89
|
+
- **Web Adapter** - Uses Cache Storage + IndexedDB for browsers
|
|
90
|
+
- **Memory Adapter** - In-memory storage for testing
|
|
91
|
+
|
|
92
|
+
### Public API
|
|
93
|
+
|
|
94
|
+
Platform entrypoints automatically select the appropriate adapter:
|
|
95
|
+
|
|
96
|
+
- `index.native.ts` - React Native (iOS/Android)
|
|
97
|
+
- `index.web.ts` - Web browsers
|
|
98
|
+
|
|
99
|
+
## Adapters
|
|
100
|
+
|
|
101
|
+
### Using the Default Adapter
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// React Native - automatically uses RNFS adapter
|
|
105
|
+
import { createImmutableFileCache } from "@dynlabs/react-native-immutable-file-cache";
|
|
106
|
+
|
|
107
|
+
// Web - automatically uses Web adapter
|
|
108
|
+
import { createImmutableFileCache } from "@dynlabs/react-native-immutable-file-cache/web";
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Injecting a Custom Adapter
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import { createImmutableFileCache, createRnfsAdapter } from "@dynlabs/react-native-immutable-file-cache";
|
|
115
|
+
|
|
116
|
+
const customAdapter = createRnfsAdapter({
|
|
117
|
+
baseDir: "/custom/path",
|
|
118
|
+
namespace: "my-app",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const cache = await createImmutableFileCache({
|
|
122
|
+
adapter: customAdapter,
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Building a Custom Adapter
|
|
127
|
+
|
|
128
|
+
Implement the `IStorageAdapter` interface:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import type { IStorageAdapter, TBinarySource, IBinaryWriteResult } from "@dynlabs/react-native-immutable-file-cache";
|
|
132
|
+
|
|
133
|
+
const myAdapter: IStorageAdapter = {
|
|
134
|
+
kind: "my-storage",
|
|
135
|
+
rootId: "my-root",
|
|
136
|
+
|
|
137
|
+
async ensureDir(path) { /* Create directory */ },
|
|
138
|
+
async exists(path) { /* Check if exists */ },
|
|
139
|
+
async remove(path) { /* Remove file */ },
|
|
140
|
+
async removeDir(path) { /* Remove directory recursively */ },
|
|
141
|
+
async listDir(path) { /* List directory contents */ },
|
|
142
|
+
async readText(path, encoding) { /* Read text file */ },
|
|
143
|
+
async writeTextAtomic(path, content, encoding) { /* Write text atomically */ },
|
|
144
|
+
async stat(path) { /* Get file stats */ },
|
|
145
|
+
async writeBinaryAtomic(path, source, options) { /* Write binary atomically */ },
|
|
146
|
+
async getPublicUri(path) { /* Get URI for file */ },
|
|
147
|
+
};
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Supported Binary Sources by Adapter
|
|
151
|
+
|
|
152
|
+
| Source Type | RNFS Adapter | Web Adapter | Memory Adapter |
|
|
153
|
+
|-------------|--------------|-------------|----------------|
|
|
154
|
+
| `url` | ✅ | ✅ | ✅ |
|
|
155
|
+
| `file` | ✅ | ❌ | ✅ (simulated) |
|
|
156
|
+
| `blob` | ❌ | ✅ | ✅ |
|
|
157
|
+
| `bytes` | ✅ | ✅ | ✅ |
|
|
158
|
+
|
|
159
|
+
## API Reference
|
|
160
|
+
|
|
161
|
+
### `createImmutableFileCache(options?)`
|
|
162
|
+
|
|
163
|
+
Creates a new cache instance.
|
|
164
|
+
|
|
165
|
+
#### Options
|
|
166
|
+
|
|
167
|
+
| Option | Type | Default | Description |
|
|
168
|
+
|--------|------|---------|-------------|
|
|
169
|
+
| `namespace` | `string` | `"default"` | Cache namespace for isolation |
|
|
170
|
+
| `adapter` | `IStorageAdapter` | auto-detected | Custom storage adapter |
|
|
171
|
+
| `defaultTtlMs` | `number` | `undefined` | Default TTL for entries |
|
|
172
|
+
| `maxSizeBytes` | `number` | `undefined` | Max cache size (triggers LRU) |
|
|
173
|
+
| `autoPruneExpired` | `boolean` | `true` | Auto-prune on operations |
|
|
174
|
+
| `hashFn` | `(input: string) => string \| Promise<string>` | SHA-256 | Custom hash function |
|
|
175
|
+
|
|
176
|
+
### Put Operations
|
|
177
|
+
|
|
178
|
+
All put operations are immutable - they return `{ status: "exists" }` if the key already exists.
|
|
179
|
+
|
|
180
|
+
#### `putFromUrl(key, url, options?)`
|
|
181
|
+
|
|
182
|
+
Cache content from a URL.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
const result = await cache.putFromUrl("image-key", "https://example.com/image.jpg", {
|
|
186
|
+
ttlMs: 86400000, // 1 day
|
|
187
|
+
ext: ".jpg",
|
|
188
|
+
metadata: { source: "cdn" },
|
|
189
|
+
onProgress: (pct) => console.log(`${pct}% downloaded`),
|
|
190
|
+
headers: { Authorization: "Bearer token" },
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### `putFromFile(key, filePath, options?)` (Native only)
|
|
195
|
+
|
|
196
|
+
Cache content from a local file.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
const result = await cache.putFromFile("doc-key", "/path/to/document.pdf", {
|
|
200
|
+
ext: ".pdf",
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `putFromBlob(key, blob, options?)` (Web only)
|
|
205
|
+
|
|
206
|
+
Cache content from a Blob.
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
const blob = await response.blob();
|
|
210
|
+
const result = await cache.putFromBlob("data-key", blob);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
#### `putFromBytes(key, bytes, options?)`
|
|
214
|
+
|
|
215
|
+
Cache raw bytes.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const result = await cache.putFromBytes("raw-key", new Uint8Array([1, 2, 3]));
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Get Operations
|
|
222
|
+
|
|
223
|
+
#### `get(key)`
|
|
224
|
+
|
|
225
|
+
Get entry with URI. Updates `lastAccessedAt`.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
const result = await cache.get("image-key");
|
|
229
|
+
if (result) {
|
|
230
|
+
console.log("URI:", result.uri);
|
|
231
|
+
console.log("Size:", result.entry.sizeBytes);
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### `has(key)`
|
|
236
|
+
|
|
237
|
+
Check if key exists and is not expired.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
if (await cache.has("image-key")) {
|
|
241
|
+
// Entry exists
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### `peek(key)`
|
|
246
|
+
|
|
247
|
+
Get entry metadata without updating `lastAccessedAt`.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const entry = await cache.peek("image-key");
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### List/Query Operations
|
|
254
|
+
|
|
255
|
+
#### `list(options?)`
|
|
256
|
+
|
|
257
|
+
List entries with sorting, filtering, and pagination.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
const entries = await cache.list({
|
|
261
|
+
sortBy: "createdAt", // "createdAt" | "lastAccessedAt" | "sizeBytes" | "key"
|
|
262
|
+
order: "desc", // "asc" | "desc"
|
|
263
|
+
limit: 10,
|
|
264
|
+
offset: 0,
|
|
265
|
+
filter: (entry) => entry.sizeBytes > 1000,
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
#### `stats()`
|
|
270
|
+
|
|
271
|
+
Get cache statistics.
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
const stats = await cache.stats();
|
|
275
|
+
console.log(`${stats.entryCount} entries, ${stats.totalSizeBytes} bytes`);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Remove/Prune Operations
|
|
279
|
+
|
|
280
|
+
#### `remove(key)`
|
|
281
|
+
|
|
282
|
+
Remove specific entry.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
const removed = await cache.remove("image-key");
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### `removeExpired()`
|
|
289
|
+
|
|
290
|
+
Remove all expired entries.
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
const result = await cache.removeExpired();
|
|
294
|
+
console.log(`Removed ${result.removedCount} entries, freed ${result.freedBytes} bytes`);
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
#### `pruneLru(maxSizeBytes)`
|
|
298
|
+
|
|
299
|
+
Prune to fit size limit using LRU.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
await cache.pruneLru(50 * 1024 * 1024); // Prune to 50MB
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### `clear()`
|
|
306
|
+
|
|
307
|
+
Remove all entries.
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
await cache.clear();
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Maintenance
|
|
314
|
+
|
|
315
|
+
#### `validateAndRepair()`
|
|
316
|
+
|
|
317
|
+
Validate index against filesystem and rebuild if needed.
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
const result = await cache.validateAndRepair();
|
|
321
|
+
if (result.repaired) {
|
|
322
|
+
console.log("Fixed issues:", result.issues);
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Web-Specific: Blob URL Cleanup
|
|
327
|
+
|
|
328
|
+
On web, `getPublicUri()` returns `blob:` URLs. To prevent memory leaks, clean up URLs when done:
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
import { createWebAdapter, IWebAdapterWithCleanup } from "@dynlabs/react-native-immutable-file-cache";
|
|
332
|
+
|
|
333
|
+
const adapter = createWebAdapter() as IWebAdapterWithCleanup;
|
|
334
|
+
|
|
335
|
+
// When done with a specific URL
|
|
336
|
+
adapter.revokeUri(entry.uri);
|
|
337
|
+
|
|
338
|
+
// Or revoke all URLs
|
|
339
|
+
adapter.revokeAllUris();
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Error Handling
|
|
343
|
+
|
|
344
|
+
The package provides typed errors:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import {
|
|
348
|
+
CacheError,
|
|
349
|
+
UnsupportedSourceError,
|
|
350
|
+
AdapterIOError,
|
|
351
|
+
CorruptIndexError,
|
|
352
|
+
ImmutableConflictError,
|
|
353
|
+
EntryNotFoundError,
|
|
354
|
+
} from "@dynlabs/react-native-immutable-file-cache";
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
await cache.putFromBlob("key", blob);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof UnsupportedSourceError) {
|
|
360
|
+
console.log(`${error.sourceType} not supported on ${error.adapterKind}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Future Adapters
|
|
366
|
+
|
|
367
|
+
The adapter architecture makes it easy to add support for:
|
|
368
|
+
|
|
369
|
+
- `react-native-blob-util`
|
|
370
|
+
- `expo-file-system`
|
|
371
|
+
- S3-backed remote cache
|
|
372
|
+
- SQLite-based storage
|
|
373
|
+
- Custom encrypted storage
|
|
374
|
+
|
|
375
|
+
To add a new adapter, implement `IStorageAdapter` and pass it to `createImmutableFileCache({ adapter })`.
|
|
376
|
+
|
|
377
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed instructions on adding adapters.
|
|
378
|
+
|
|
379
|
+
## Testing
|
|
380
|
+
|
|
381
|
+
The package includes a `MemoryAdapter` for testing:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
import { CacheEngine, createMemoryAdapter } from "@dynlabs/react-native-immutable-file-cache";
|
|
385
|
+
|
|
386
|
+
const adapter = createMemoryAdapter("test");
|
|
387
|
+
const cache = new CacheEngine({ namespace: "test" }, adapter);
|
|
388
|
+
await cache.init();
|
|
389
|
+
|
|
390
|
+
// Run tests...
|
|
391
|
+
|
|
392
|
+
// Clean up
|
|
393
|
+
adapter._reset();
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Contributing
|
|
397
|
+
|
|
398
|
+
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details.
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
# Clone and install
|
|
402
|
+
git clone https://github.com/dienp/react-native-immutable-file-cache.git
|
|
403
|
+
cd react-native-immutable-file-cache
|
|
404
|
+
npm install
|
|
405
|
+
|
|
406
|
+
# Development
|
|
407
|
+
npm run typecheck # Type check
|
|
408
|
+
npm run lint # Lint
|
|
409
|
+
npm test # Run tests
|
|
410
|
+
npm run build # Build
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## License
|
|
414
|
+
|
|
415
|
+
MIT
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createMemoryAdapter = createMemoryAdapter;
|
|
7
|
+
var _errors = require("../core/errors");
|
|
8
|
+
/**
|
|
9
|
+
* In-Memory Storage Adapter
|
|
10
|
+
*
|
|
11
|
+
* For testing purposes only.
|
|
12
|
+
* Implements IStorageAdapter using in-memory storage.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* eslint-disable @typescript-eslint/require-await */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extended interface for memory adapter with test utilities.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates an in-memory storage adapter for testing.
|
|
23
|
+
*/
|
|
24
|
+
function createMemoryAdapter(namespace) {
|
|
25
|
+
const ns = namespace ?? "default";
|
|
26
|
+
const rootId = `memory://${ns}`;
|
|
27
|
+
|
|
28
|
+
// In-memory storage
|
|
29
|
+
const files = new Map();
|
|
30
|
+
const directories = new Set();
|
|
31
|
+
|
|
32
|
+
// Track blob URLs for cleanup
|
|
33
|
+
const blobUrls = new Map();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalizes path by removing leading/trailing slashes.
|
|
37
|
+
*/
|
|
38
|
+
const normalizePath = path => {
|
|
39
|
+
return path.replace(/^\/+|\/+$/g, "");
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Gets the parent directory of a path.
|
|
44
|
+
*/
|
|
45
|
+
const getParentDir = path => {
|
|
46
|
+
const lastSlash = path.lastIndexOf("/");
|
|
47
|
+
return lastSlash > 0 ? path.substring(0, lastSlash) : null;
|
|
48
|
+
};
|
|
49
|
+
const adapter = {
|
|
50
|
+
kind: "memory",
|
|
51
|
+
rootId,
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────
|
|
53
|
+
// Directory Management
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
async ensureDir(path) {
|
|
57
|
+
const normalized = normalizePath(path);
|
|
58
|
+
if (normalized) {
|
|
59
|
+
directories.add(normalized);
|
|
60
|
+
// Also ensure parent directories
|
|
61
|
+
const parent = getParentDir(normalized);
|
|
62
|
+
if (parent) {
|
|
63
|
+
await this.ensureDir(parent);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async exists(path) {
|
|
68
|
+
const normalized = normalizePath(path);
|
|
69
|
+
return files.has(normalized) || directories.has(normalized);
|
|
70
|
+
},
|
|
71
|
+
async remove(path) {
|
|
72
|
+
const normalized = normalizePath(path);
|
|
73
|
+
files.delete(normalized);
|
|
74
|
+
},
|
|
75
|
+
async removeDir(path) {
|
|
76
|
+
const normalized = normalizePath(path);
|
|
77
|
+
const prefix = normalized + "/";
|
|
78
|
+
|
|
79
|
+
// Remove all files with this prefix
|
|
80
|
+
for (const key of files.keys()) {
|
|
81
|
+
if (key === normalized || key.startsWith(prefix)) {
|
|
82
|
+
files.delete(key);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove all directories with this prefix
|
|
87
|
+
for (const dir of directories) {
|
|
88
|
+
if (dir === normalized || dir.startsWith(prefix)) {
|
|
89
|
+
directories.delete(dir);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async listDir(path) {
|
|
94
|
+
const normalized = normalizePath(path);
|
|
95
|
+
const prefix = normalized ? normalized + "/" : "";
|
|
96
|
+
const entries = new Set();
|
|
97
|
+
|
|
98
|
+
// Find all files in this directory
|
|
99
|
+
for (const key of files.keys()) {
|
|
100
|
+
if (key.startsWith(prefix)) {
|
|
101
|
+
const relativePath = key.slice(prefix.length);
|
|
102
|
+
const firstSlash = relativePath.indexOf("/");
|
|
103
|
+
if (firstSlash === -1) {
|
|
104
|
+
// Direct child file
|
|
105
|
+
entries.add(relativePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Array.from(entries);
|
|
110
|
+
},
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────
|
|
112
|
+
// File I/O
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
async readText(path, _encoding) {
|
|
116
|
+
const normalized = normalizePath(path);
|
|
117
|
+
const entry = files.get(normalized);
|
|
118
|
+
if (!entry) {
|
|
119
|
+
throw new _errors.AdapterIOError("readText", path, new Error("File not found"));
|
|
120
|
+
}
|
|
121
|
+
const decoder = new TextDecoder("utf-8");
|
|
122
|
+
return decoder.decode(entry.content);
|
|
123
|
+
},
|
|
124
|
+
async writeTextAtomic(path, content, _encoding) {
|
|
125
|
+
const normalized = normalizePath(path);
|
|
126
|
+
const encoder = new TextEncoder();
|
|
127
|
+
const bytes = encoder.encode(content);
|
|
128
|
+
files.set(normalized, {
|
|
129
|
+
content: bytes,
|
|
130
|
+
mtime: Date.now(),
|
|
131
|
+
contentType: "text/plain"
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Ensure parent directory exists
|
|
135
|
+
const parent = getParentDir(normalized);
|
|
136
|
+
if (parent) {
|
|
137
|
+
directories.add(parent);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
async stat(path) {
|
|
141
|
+
const normalized = normalizePath(path);
|
|
142
|
+
const entry = files.get(normalized);
|
|
143
|
+
if (!entry) {
|
|
144
|
+
throw new _errors.AdapterIOError("stat", path, new Error("File not found"));
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
sizeBytes: entry.content.length,
|
|
148
|
+
mtimeMs: entry.mtime
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
async writeBinaryAtomic(path, source, options) {
|
|
152
|
+
const normalized = normalizePath(path);
|
|
153
|
+
let content;
|
|
154
|
+
let contentType;
|
|
155
|
+
switch (source.type) {
|
|
156
|
+
case "url":
|
|
157
|
+
{
|
|
158
|
+
// Simulate URL fetch
|
|
159
|
+
try {
|
|
160
|
+
const response = await fetch(source.url, {
|
|
161
|
+
headers: {
|
|
162
|
+
...source.headers,
|
|
163
|
+
...options?.headers
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Fetch failed: ${response.status}`);
|
|
168
|
+
}
|
|
169
|
+
contentType = response.headers.get("content-type") ?? undefined;
|
|
170
|
+
const buffer = await response.arrayBuffer();
|
|
171
|
+
content = new Uint8Array(buffer);
|
|
172
|
+
options?.onProgress?.(100);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
throw new _errors.AdapterIOError("writeBinaryAtomic", path, error);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "file":
|
|
179
|
+
{
|
|
180
|
+
// Simulate file copy (just use path as content for testing)
|
|
181
|
+
const encoder = new TextEncoder();
|
|
182
|
+
content = encoder.encode(`[file:${source.filePath}]`);
|
|
183
|
+
options?.onProgress?.(100);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case "blob":
|
|
187
|
+
{
|
|
188
|
+
const buffer = await source.blob.arrayBuffer();
|
|
189
|
+
content = new Uint8Array(buffer);
|
|
190
|
+
contentType = source.blob.type || undefined;
|
|
191
|
+
options?.onProgress?.(100);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "bytes":
|
|
195
|
+
{
|
|
196
|
+
content = source.bytes;
|
|
197
|
+
options?.onProgress?.(100);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
default:
|
|
201
|
+
{
|
|
202
|
+
const _exhaustive = source;
|
|
203
|
+
throw new _errors.AdapterIOError("writeBinaryAtomic", path, new Error(`Unknown source type: ${_exhaustive.type}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
files.set(normalized, {
|
|
207
|
+
content,
|
|
208
|
+
mtime: Date.now(),
|
|
209
|
+
contentType
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Ensure parent directory exists
|
|
213
|
+
const parent = getParentDir(normalized);
|
|
214
|
+
if (parent) {
|
|
215
|
+
directories.add(parent);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
sizeBytes: content.length,
|
|
219
|
+
contentType
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
async getPublicUri(path) {
|
|
223
|
+
const normalized = normalizePath(path);
|
|
224
|
+
const entry = files.get(normalized);
|
|
225
|
+
if (!entry) {
|
|
226
|
+
throw new _errors.AdapterIOError("getPublicUri", path, new Error("File not found"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check if we already have a blob URL for this path
|
|
230
|
+
const existing = blobUrls.get(normalized);
|
|
231
|
+
if (existing) {
|
|
232
|
+
return existing;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Create blob URL
|
|
236
|
+
const blob = new Blob([entry.content.buffer], {
|
|
237
|
+
type: entry.contentType ?? "application/octet-stream"
|
|
238
|
+
});
|
|
239
|
+
const url = URL.createObjectURL(blob);
|
|
240
|
+
blobUrls.set(normalized, url);
|
|
241
|
+
return url;
|
|
242
|
+
},
|
|
243
|
+
// ─────────────────────────────────────────────────────────────────
|
|
244
|
+
// Test Utilities
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
_reset() {
|
|
248
|
+
files.clear();
|
|
249
|
+
directories.clear();
|
|
250
|
+
// Revoke all blob URLs
|
|
251
|
+
for (const url of blobUrls.values()) {
|
|
252
|
+
URL.revokeObjectURL(url);
|
|
253
|
+
}
|
|
254
|
+
blobUrls.clear();
|
|
255
|
+
},
|
|
256
|
+
_getContent(path) {
|
|
257
|
+
const normalized = normalizePath(path);
|
|
258
|
+
return files.get(normalized)?.content;
|
|
259
|
+
},
|
|
260
|
+
_getAllPaths() {
|
|
261
|
+
return Array.from(files.keys());
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
return adapter;
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=memoryAdapter.js.map
|