@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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* For testing purposes only.
|
|
5
|
+
* Implements IStorageAdapter using in-memory storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/require-await */
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
IStorageAdapter,
|
|
12
|
+
TAdapterPath,
|
|
13
|
+
TBinarySource,
|
|
14
|
+
IBinaryWriteResult,
|
|
15
|
+
IBinaryWriteOptions,
|
|
16
|
+
IFileStat,
|
|
17
|
+
} from "../core/adapter";
|
|
18
|
+
import { AdapterIOError } from "../core/errors";
|
|
19
|
+
|
|
20
|
+
interface IFileEntry {
|
|
21
|
+
content: Uint8Array;
|
|
22
|
+
mtime: number;
|
|
23
|
+
contentType?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extended interface for memory adapter with test utilities.
|
|
28
|
+
*/
|
|
29
|
+
export interface IMemoryAdapter extends IStorageAdapter {
|
|
30
|
+
/** Resets all stored data. For testing. */
|
|
31
|
+
_reset(): void;
|
|
32
|
+
/** Gets raw content for assertions. For testing. */
|
|
33
|
+
_getContent(path: string): Uint8Array | undefined;
|
|
34
|
+
/** Gets all stored paths. For testing. */
|
|
35
|
+
_getAllPaths(): string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates an in-memory storage adapter for testing.
|
|
40
|
+
*/
|
|
41
|
+
export function createMemoryAdapter(namespace?: string): IMemoryAdapter {
|
|
42
|
+
const ns = namespace ?? "default";
|
|
43
|
+
const rootId = `memory://${ns}`;
|
|
44
|
+
|
|
45
|
+
// In-memory storage
|
|
46
|
+
const files = new Map<string, IFileEntry>();
|
|
47
|
+
const directories = new Set<string>();
|
|
48
|
+
|
|
49
|
+
// Track blob URLs for cleanup
|
|
50
|
+
const blobUrls = new Map<string, string>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalizes path by removing leading/trailing slashes.
|
|
54
|
+
*/
|
|
55
|
+
const normalizePath = (path: TAdapterPath): string => {
|
|
56
|
+
return path.replace(/^\/+|\/+$/g, "");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Gets the parent directory of a path.
|
|
61
|
+
*/
|
|
62
|
+
const getParentDir = (path: string): string | null => {
|
|
63
|
+
const lastSlash = path.lastIndexOf("/");
|
|
64
|
+
return lastSlash > 0 ? path.substring(0, lastSlash) : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const adapter: IMemoryAdapter = {
|
|
68
|
+
kind: "memory",
|
|
69
|
+
rootId,
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────
|
|
72
|
+
// Directory Management
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async ensureDir(path: TAdapterPath): Promise<void> {
|
|
76
|
+
const normalized = normalizePath(path);
|
|
77
|
+
if (normalized) {
|
|
78
|
+
directories.add(normalized);
|
|
79
|
+
// Also ensure parent directories
|
|
80
|
+
const parent = getParentDir(normalized);
|
|
81
|
+
if (parent) {
|
|
82
|
+
await this.ensureDir(parent);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async exists(path: TAdapterPath): Promise<boolean> {
|
|
88
|
+
const normalized = normalizePath(path);
|
|
89
|
+
return files.has(normalized) || directories.has(normalized);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async remove(path: TAdapterPath): Promise<void> {
|
|
93
|
+
const normalized = normalizePath(path);
|
|
94
|
+
files.delete(normalized);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async removeDir(path: TAdapterPath): Promise<void> {
|
|
98
|
+
const normalized = normalizePath(path);
|
|
99
|
+
const prefix = normalized + "/";
|
|
100
|
+
|
|
101
|
+
// Remove all files with this prefix
|
|
102
|
+
for (const key of files.keys()) {
|
|
103
|
+
if (key === normalized || key.startsWith(prefix)) {
|
|
104
|
+
files.delete(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Remove all directories with this prefix
|
|
109
|
+
for (const dir of directories) {
|
|
110
|
+
if (dir === normalized || dir.startsWith(prefix)) {
|
|
111
|
+
directories.delete(dir);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async listDir(path: TAdapterPath): Promise<ReadonlyArray<TAdapterPath>> {
|
|
117
|
+
const normalized = normalizePath(path);
|
|
118
|
+
const prefix = normalized ? normalized + "/" : "";
|
|
119
|
+
const entries = new Set<string>();
|
|
120
|
+
|
|
121
|
+
// Find all files in this directory
|
|
122
|
+
for (const key of files.keys()) {
|
|
123
|
+
if (key.startsWith(prefix)) {
|
|
124
|
+
const relativePath = key.slice(prefix.length);
|
|
125
|
+
const firstSlash = relativePath.indexOf("/");
|
|
126
|
+
if (firstSlash === -1) {
|
|
127
|
+
// Direct child file
|
|
128
|
+
entries.add(relativePath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return Array.from(entries);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────
|
|
137
|
+
// File I/O
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
async readText(path: TAdapterPath, _encoding: "utf8"): Promise<string> {
|
|
141
|
+
const normalized = normalizePath(path);
|
|
142
|
+
const entry = files.get(normalized);
|
|
143
|
+
if (!entry) {
|
|
144
|
+
throw new AdapterIOError("readText", path, new Error("File not found"));
|
|
145
|
+
}
|
|
146
|
+
const decoder = new TextDecoder("utf-8");
|
|
147
|
+
return decoder.decode(entry.content);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async writeTextAtomic(path: TAdapterPath, content: string, _encoding: "utf8"): Promise<void> {
|
|
151
|
+
const normalized = normalizePath(path);
|
|
152
|
+
const encoder = new TextEncoder();
|
|
153
|
+
const bytes = encoder.encode(content);
|
|
154
|
+
|
|
155
|
+
files.set(normalized, {
|
|
156
|
+
content: bytes,
|
|
157
|
+
mtime: Date.now(),
|
|
158
|
+
contentType: "text/plain",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Ensure parent directory exists
|
|
162
|
+
const parent = getParentDir(normalized);
|
|
163
|
+
if (parent) {
|
|
164
|
+
directories.add(parent);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async stat(path: TAdapterPath): Promise<IFileStat> {
|
|
169
|
+
const normalized = normalizePath(path);
|
|
170
|
+
const entry = files.get(normalized);
|
|
171
|
+
if (!entry) {
|
|
172
|
+
throw new AdapterIOError("stat", path, new Error("File not found"));
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
sizeBytes: entry.content.length,
|
|
176
|
+
mtimeMs: entry.mtime,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async writeBinaryAtomic(
|
|
181
|
+
path: TAdapterPath,
|
|
182
|
+
source: TBinarySource,
|
|
183
|
+
options?: IBinaryWriteOptions
|
|
184
|
+
): Promise<IBinaryWriteResult> {
|
|
185
|
+
const normalized = normalizePath(path);
|
|
186
|
+
let content: Uint8Array;
|
|
187
|
+
let contentType: string | undefined;
|
|
188
|
+
|
|
189
|
+
switch (source.type) {
|
|
190
|
+
case "url": {
|
|
191
|
+
// Simulate URL fetch
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch(source.url, {
|
|
194
|
+
headers: { ...source.headers, ...options?.headers },
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(`Fetch failed: ${response.status}`);
|
|
198
|
+
}
|
|
199
|
+
contentType = response.headers.get("content-type") ?? undefined;
|
|
200
|
+
const buffer = await response.arrayBuffer();
|
|
201
|
+
content = new Uint8Array(buffer);
|
|
202
|
+
options?.onProgress?.(100);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new AdapterIOError("writeBinaryAtomic", path, error as Error);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "file": {
|
|
210
|
+
// Simulate file copy (just use path as content for testing)
|
|
211
|
+
const encoder = new TextEncoder();
|
|
212
|
+
content = encoder.encode(`[file:${source.filePath}]`);
|
|
213
|
+
options?.onProgress?.(100);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "blob": {
|
|
218
|
+
const buffer = await source.blob.arrayBuffer();
|
|
219
|
+
content = new Uint8Array(buffer);
|
|
220
|
+
contentType = source.blob.type || undefined;
|
|
221
|
+
options?.onProgress?.(100);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "bytes": {
|
|
226
|
+
content = source.bytes;
|
|
227
|
+
options?.onProgress?.(100);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
default: {
|
|
232
|
+
const _exhaustive: never = source;
|
|
233
|
+
throw new AdapterIOError(
|
|
234
|
+
"writeBinaryAtomic",
|
|
235
|
+
path,
|
|
236
|
+
new Error(`Unknown source type: ${(_exhaustive as TBinarySource).type}`)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
files.set(normalized, {
|
|
242
|
+
content,
|
|
243
|
+
mtime: Date.now(),
|
|
244
|
+
contentType,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Ensure parent directory exists
|
|
248
|
+
const parent = getParentDir(normalized);
|
|
249
|
+
if (parent) {
|
|
250
|
+
directories.add(parent);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
sizeBytes: content.length,
|
|
255
|
+
contentType,
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async getPublicUri(path: TAdapterPath): Promise<string> {
|
|
260
|
+
const normalized = normalizePath(path);
|
|
261
|
+
const entry = files.get(normalized);
|
|
262
|
+
if (!entry) {
|
|
263
|
+
throw new AdapterIOError("getPublicUri", path, new Error("File not found"));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check if we already have a blob URL for this path
|
|
267
|
+
const existing = blobUrls.get(normalized);
|
|
268
|
+
if (existing) {
|
|
269
|
+
return existing;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Create blob URL
|
|
273
|
+
const blob = new Blob([entry.content.buffer as ArrayBuffer], {
|
|
274
|
+
type: entry.contentType ?? "application/octet-stream",
|
|
275
|
+
});
|
|
276
|
+
const url = URL.createObjectURL(blob);
|
|
277
|
+
blobUrls.set(normalized, url);
|
|
278
|
+
|
|
279
|
+
return url;
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// ─────────────────────────────────────────────────────────────────
|
|
283
|
+
// Test Utilities
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
_reset(): void {
|
|
287
|
+
files.clear();
|
|
288
|
+
directories.clear();
|
|
289
|
+
// Revoke all blob URLs
|
|
290
|
+
for (const url of blobUrls.values()) {
|
|
291
|
+
URL.revokeObjectURL(url);
|
|
292
|
+
}
|
|
293
|
+
blobUrls.clear();
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
_getContent(path: string): Uint8Array | undefined {
|
|
297
|
+
const normalized = normalizePath(path);
|
|
298
|
+
return files.get(normalized)?.content;
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
_getAllPaths(): string[] {
|
|
302
|
+
return Array.from(files.keys());
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return adapter;
|
|
307
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native FS Adapter
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY file that imports react-native-fs.
|
|
5
|
+
* Implements IStorageAdapter using RNFS APIs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
9
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
10
|
+
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
11
|
+
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
13
|
+
/* eslint-disable @typescript-eslint/require-await */
|
|
14
|
+
|
|
15
|
+
// RNFS types are not fully typed, disable unsafe rules for this adapter
|
|
16
|
+
import RNFS, { type DownloadResult } from "react-native-fs";
|
|
17
|
+
import type {
|
|
18
|
+
IStorageAdapter,
|
|
19
|
+
TAdapterPath,
|
|
20
|
+
TBinarySource,
|
|
21
|
+
IBinaryWriteResult,
|
|
22
|
+
IBinaryWriteOptions,
|
|
23
|
+
IFileStat,
|
|
24
|
+
} from "../core/adapter";
|
|
25
|
+
import { UnsupportedSourceError, AdapterIOError } from "../core/errors";
|
|
26
|
+
|
|
27
|
+
export interface IRnfsAdapterOptions {
|
|
28
|
+
/** Base directory. @default RNFS.CachesDirectoryPath */
|
|
29
|
+
readonly baseDir?: string;
|
|
30
|
+
/** Namespace subdirectory. */
|
|
31
|
+
readonly namespace?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a storage adapter backed by react-native-fs.
|
|
36
|
+
*/
|
|
37
|
+
export function createRnfsAdapter(options?: IRnfsAdapterOptions): IStorageAdapter {
|
|
38
|
+
const baseDir = options?.baseDir ?? RNFS.CachesDirectoryPath;
|
|
39
|
+
const namespace = options?.namespace ?? "default";
|
|
40
|
+
const rootPath = `${baseDir}/immutable-cache/${namespace}`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Converts adapter-relative path to absolute filesystem path.
|
|
44
|
+
*/
|
|
45
|
+
const toAbsolute = (path: TAdapterPath): string => {
|
|
46
|
+
return `${rootPath}/${path}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generates a unique temp file path for atomic writes.
|
|
51
|
+
*/
|
|
52
|
+
const getTempPath = (path: TAdapterPath): string => {
|
|
53
|
+
return `${toAbsolute(path)}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const adapter: IStorageAdapter = {
|
|
57
|
+
kind: "native-fs",
|
|
58
|
+
rootId: rootPath,
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────
|
|
61
|
+
// Directory Management
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async ensureDir(path: TAdapterPath): Promise<void> {
|
|
65
|
+
try {
|
|
66
|
+
const absPath = toAbsolute(path);
|
|
67
|
+
await RNFS.mkdir(absPath);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new AdapterIOError("ensureDir", path, error as Error);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async exists(path: TAdapterPath): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
const absPath = toAbsolute(path);
|
|
76
|
+
return await RNFS.exists(absPath);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async remove(path: TAdapterPath): Promise<void> {
|
|
83
|
+
try {
|
|
84
|
+
const absPath = toAbsolute(path);
|
|
85
|
+
const exists = await RNFS.exists(absPath);
|
|
86
|
+
if (exists) {
|
|
87
|
+
await RNFS.unlink(absPath);
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new AdapterIOError("remove", path, error as Error);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async removeDir(path: TAdapterPath): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
const absPath = toAbsolute(path);
|
|
97
|
+
const exists = await RNFS.exists(absPath);
|
|
98
|
+
if (exists) {
|
|
99
|
+
// RNFS.unlink can remove directories recursively
|
|
100
|
+
await RNFS.unlink(absPath);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new AdapterIOError("removeDir", path, error as Error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async listDir(path: TAdapterPath): Promise<ReadonlyArray<TAdapterPath>> {
|
|
108
|
+
try {
|
|
109
|
+
const absPath = toAbsolute(path);
|
|
110
|
+
const exists = await RNFS.exists(absPath);
|
|
111
|
+
if (!exists) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const items = await RNFS.readDir(absPath);
|
|
115
|
+
return items.map((item: { name: string }) => item.name);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new AdapterIOError("listDir", path, error as Error);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────
|
|
122
|
+
// File I/O
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async readText(path: TAdapterPath, _encoding: "utf8"): Promise<string> {
|
|
126
|
+
try {
|
|
127
|
+
const absPath = toAbsolute(path);
|
|
128
|
+
return await RNFS.readFile(absPath, "utf8");
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new AdapterIOError("readText", path, error as Error);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async writeTextAtomic(path: TAdapterPath, content: string, _encoding: "utf8"): Promise<void> {
|
|
135
|
+
const tempPath = getTempPath(path);
|
|
136
|
+
const absPath = toAbsolute(path);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Ensure parent directory exists
|
|
140
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/"));
|
|
141
|
+
await RNFS.mkdir(parentDir);
|
|
142
|
+
|
|
143
|
+
// Write to temp file
|
|
144
|
+
await RNFS.writeFile(tempPath, content, "utf8");
|
|
145
|
+
|
|
146
|
+
// Atomic move
|
|
147
|
+
await RNFS.moveFile(tempPath, absPath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Clean up temp file on error
|
|
150
|
+
try {
|
|
151
|
+
await RNFS.unlink(tempPath);
|
|
152
|
+
} catch {
|
|
153
|
+
// Ignore cleanup errors
|
|
154
|
+
}
|
|
155
|
+
throw new AdapterIOError("writeTextAtomic", path, error as Error);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async stat(path: TAdapterPath): Promise<IFileStat> {
|
|
160
|
+
try {
|
|
161
|
+
const absPath = toAbsolute(path);
|
|
162
|
+
const statResult = await RNFS.stat(absPath);
|
|
163
|
+
return {
|
|
164
|
+
sizeBytes: statResult.size,
|
|
165
|
+
mtimeMs: new Date(statResult.mtime).getTime(),
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new AdapterIOError("stat", path, error as Error);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async writeBinaryAtomic(
|
|
173
|
+
path: TAdapterPath,
|
|
174
|
+
source: TBinarySource,
|
|
175
|
+
options?: IBinaryWriteOptions
|
|
176
|
+
): Promise<IBinaryWriteResult> {
|
|
177
|
+
const tempPath = getTempPath(path);
|
|
178
|
+
const absPath = toAbsolute(path);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Ensure parent directory exists
|
|
182
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/"));
|
|
183
|
+
await RNFS.mkdir(parentDir);
|
|
184
|
+
|
|
185
|
+
let contentType: string | undefined;
|
|
186
|
+
|
|
187
|
+
switch (source.type) {
|
|
188
|
+
case "url": {
|
|
189
|
+
// Download file with progress
|
|
190
|
+
const downloadResult = await new Promise<DownloadResult>((resolve, reject) => {
|
|
191
|
+
const { promise } = RNFS.downloadFile({
|
|
192
|
+
fromUrl: source.url,
|
|
193
|
+
toFile: tempPath,
|
|
194
|
+
headers: { ...source.headers, ...options?.headers },
|
|
195
|
+
progress: (res: { contentLength: number; bytesWritten: number }) => {
|
|
196
|
+
if (options?.onProgress && res.contentLength > 0) {
|
|
197
|
+
const pct = (res.bytesWritten / res.contentLength) * 100;
|
|
198
|
+
options.onProgress(pct);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
progressDivider: 1,
|
|
202
|
+
});
|
|
203
|
+
promise.then(resolve).catch(reject);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (downloadResult.statusCode < 200 || downloadResult.statusCode >= 300) {
|
|
207
|
+
throw new Error(`Download failed with status ${downloadResult.statusCode}`);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case "file": {
|
|
213
|
+
// Copy file
|
|
214
|
+
await RNFS.copyFile(source.filePath, tempPath);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "bytes": {
|
|
219
|
+
// Convert Uint8Array to base64 and write
|
|
220
|
+
const base64 = uint8ArrayToBase64(source.bytes);
|
|
221
|
+
await RNFS.writeFile(tempPath, base64, "base64");
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "blob": {
|
|
226
|
+
// Blob is not supported on native
|
|
227
|
+
throw new UnsupportedSourceError("blob", "native-fs");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
default: {
|
|
231
|
+
const _exhaustive: never = source;
|
|
232
|
+
throw new UnsupportedSourceError((_exhaustive as TBinarySource).type, "native-fs");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Get size before moving
|
|
237
|
+
const statResult = await RNFS.stat(tempPath);
|
|
238
|
+
|
|
239
|
+
// Atomic move
|
|
240
|
+
await RNFS.moveFile(tempPath, absPath);
|
|
241
|
+
|
|
242
|
+
// Report 100% progress
|
|
243
|
+
options?.onProgress?.(100);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
sizeBytes: statResult.size,
|
|
247
|
+
contentType,
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Clean up temp file on error
|
|
251
|
+
try {
|
|
252
|
+
await RNFS.unlink(tempPath);
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore cleanup errors
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (error instanceof UnsupportedSourceError) {
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
throw new AdapterIOError("writeBinaryAtomic", path, error as Error);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async getPublicUri(path: TAdapterPath): Promise<string> {
|
|
265
|
+
const absPath = toAbsolute(path);
|
|
266
|
+
// Return file:// URI for native platforms
|
|
267
|
+
return `file://${absPath}`;
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return adapter;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Converts Uint8Array to base64 string.
|
|
276
|
+
*/
|
|
277
|
+
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
278
|
+
let binary = "";
|
|
279
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
280
|
+
binary += String.fromCharCode(bytes[i]);
|
|
281
|
+
}
|
|
282
|
+
return btoa(binary);
|
|
283
|
+
}
|