@remix-run/file-storage 0.8.0
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 +83 -0
- package/dist/file-storage.cjs +19 -0
- package/dist/file-storage.cjs.map +7 -0
- package/dist/file-storage.d.ts +2 -0
- package/dist/file-storage.d.ts.map +1 -0
- package/dist/file-storage.js +1 -0
- package/dist/file-storage.js.map +7 -0
- package/dist/lib/file-storage.d.ts +158 -0
- package/dist/lib/file-storage.d.ts.map +1 -0
- package/dist/lib/local-file-storage.d.ts +26 -0
- package/dist/lib/local-file-storage.d.ts.map +1 -0
- package/dist/lib/memory-file-storage.d.ts +17 -0
- package/dist/lib/memory-file-storage.d.ts.map +1 -0
- package/dist/local.cjs +904 -0
- package/dist/local.cjs.map +7 -0
- package/dist/local.d.ts +2 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +867 -0
- package/dist/local.js.map +7 -0
- package/dist/memory.cjs +86 -0
- package/dist/memory.cjs.map +7 -0
- package/dist/memory.d.ts +2 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +63 -0
- package/dist/memory.js.map +7 -0
- package/package.json +71 -0
- package/src/file-storage.ts +7 -0
- package/src/lib/file-storage.ts +164 -0
- package/src/lib/local-file-storage.ts +183 -0
- package/src/lib/memory-file-storage.ts +78 -0
- package/src/local.ts +1 -0
- package/src/memory.ts +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as fsp from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { openFile, writeFile } from '@remix-run/lazy-file/fs';
|
|
5
|
+
|
|
6
|
+
import type { FileStorage, FileMetadata, ListOptions, ListResult } from './file-storage.ts';
|
|
7
|
+
|
|
8
|
+
type MetadataJson = Omit<FileMetadata, 'size'>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A `FileStorage` that is backed by a directory on the local filesystem.
|
|
12
|
+
*
|
|
13
|
+
* Important: No attempt is made to avoid overwriting existing files, so the directory used should
|
|
14
|
+
* be a new directory solely dedicated to this storage object.
|
|
15
|
+
*
|
|
16
|
+
* Note: Keys have no correlation to file names on disk, so they may be any string including
|
|
17
|
+
* characters that are not valid in file names. Additionally, individual `File` names have no
|
|
18
|
+
* correlation to names of files on disk, so multiple files with the same name may be stored in the
|
|
19
|
+
* same storage object.
|
|
20
|
+
*/
|
|
21
|
+
export class LocalFileStorage implements FileStorage {
|
|
22
|
+
#dirname: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param directory The directory where files are stored
|
|
26
|
+
*/
|
|
27
|
+
constructor(directory: string) {
|
|
28
|
+
this.#dirname = path.resolve(directory);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
let stats = fs.statSync(this.#dirname);
|
|
32
|
+
|
|
33
|
+
if (!stats.isDirectory()) {
|
|
34
|
+
throw new Error(`Path "${this.#dirname}" is not a directory`);
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!isNoEntityError(error)) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(this.#dirname, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(key: string): Promise<File | null> {
|
|
46
|
+
let { filePath, metaPath } = await this.#getPaths(key);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
let meta = await readMetadata(metaPath);
|
|
50
|
+
|
|
51
|
+
return openFile(filePath, {
|
|
52
|
+
lastModified: meta.lastModified,
|
|
53
|
+
name: meta.name,
|
|
54
|
+
type: meta.type,
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!isNoEntityError(error)) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async has(key: string): Promise<boolean> {
|
|
66
|
+
let { metaPath } = await this.#getPaths(key);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await fsp.access(metaPath);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async list<T extends ListOptions>(options?: T): Promise<ListResult<T>> {
|
|
77
|
+
let { cursor, includeMetadata = false, limit = 32, prefix } = options ?? {};
|
|
78
|
+
|
|
79
|
+
let files: any[] = [];
|
|
80
|
+
let foundCursor = cursor === undefined;
|
|
81
|
+
let nextCursor: string | undefined;
|
|
82
|
+
let lastHash: string | undefined;
|
|
83
|
+
|
|
84
|
+
outerLoop: for await (let subdir of await fsp.opendir(this.#dirname)) {
|
|
85
|
+
if (!subdir.isDirectory()) continue;
|
|
86
|
+
|
|
87
|
+
for await (let file of await fsp.opendir(path.join(this.#dirname, subdir.name))) {
|
|
88
|
+
if (!file.isFile() || !file.name.endsWith('.meta.json')) continue;
|
|
89
|
+
|
|
90
|
+
let hash = file.name.slice(0, -10); // Remove ".meta.json"
|
|
91
|
+
|
|
92
|
+
if (foundCursor) {
|
|
93
|
+
let meta = await readMetadata(path.join(this.#dirname, subdir.name, file.name));
|
|
94
|
+
|
|
95
|
+
if (prefix != null && !meta.key.startsWith(prefix)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (files.length >= limit) {
|
|
100
|
+
nextCursor = lastHash;
|
|
101
|
+
break outerLoop;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (includeMetadata) {
|
|
105
|
+
let size = (await fsp.stat(path.join(this.#dirname, subdir.name, `${hash}.dat`))).size;
|
|
106
|
+
files.push({ ...meta, size });
|
|
107
|
+
} else {
|
|
108
|
+
files.push({ key: meta.key });
|
|
109
|
+
}
|
|
110
|
+
} else if (hash === cursor) {
|
|
111
|
+
foundCursor = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lastHash = hash;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
cursor: nextCursor,
|
|
120
|
+
files,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async put(key: string, file: File): Promise<File> {
|
|
125
|
+
await this.set(key, file);
|
|
126
|
+
return (await this.get(key))!;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async remove(key: string): Promise<void> {
|
|
130
|
+
let { filePath, metaPath } = await this.#getPaths(key);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await Promise.all([fsp.unlink(filePath), fsp.unlink(metaPath)]);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isNoEntityError(error)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async set(key: string, file: File): Promise<void> {
|
|
142
|
+
let { directory, filePath, metaPath } = await this.#getPaths(key);
|
|
143
|
+
|
|
144
|
+
// Ensure directory exists
|
|
145
|
+
await fsp.mkdir(directory, { recursive: true });
|
|
146
|
+
|
|
147
|
+
await writeFile(filePath, file);
|
|
148
|
+
|
|
149
|
+
let meta: MetadataJson = {
|
|
150
|
+
key,
|
|
151
|
+
lastModified: file.lastModified,
|
|
152
|
+
name: file.name,
|
|
153
|
+
type: file.type,
|
|
154
|
+
};
|
|
155
|
+
await fsp.writeFile(metaPath, JSON.stringify(meta));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async #getPaths(key: string): Promise<{ directory: string; filePath: string; metaPath: string }> {
|
|
159
|
+
let hash = await computeHash(key);
|
|
160
|
+
let directory = path.join(this.#dirname, hash.slice(0, 2));
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
directory,
|
|
164
|
+
filePath: path.join(directory, `${hash}.dat`),
|
|
165
|
+
metaPath: path.join(directory, `${hash}.meta.json`),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readMetadata(metaPath: string): Promise<MetadataJson> {
|
|
171
|
+
return JSON.parse(await fsp.readFile(metaPath, 'utf-8'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function computeHash(key: string, algorithm = 'SHA-256'): Promise<string> {
|
|
175
|
+
let digest = await crypto.subtle.digest(algorithm, new TextEncoder().encode(key));
|
|
176
|
+
return Array.from(new Uint8Array(digest))
|
|
177
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
178
|
+
.join('');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isNoEntityError(obj: unknown): obj is NodeJS.ErrnoException & { code: 'ENOENT' } {
|
|
182
|
+
return obj instanceof Error && 'code' in obj && (obj as NodeJS.ErrnoException).code === 'ENOENT';
|
|
183
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { FileStorage, ListOptions, ListResult } from './file-storage.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A simple, in-memory implementation of the `FileStorage` interface.
|
|
5
|
+
*
|
|
6
|
+
* Note: Any files you put in storage will have their entire contents buffered in memory, so this is not suitable for large files
|
|
7
|
+
* in production scenarios.
|
|
8
|
+
*/
|
|
9
|
+
export class MemoryFileStorage implements FileStorage {
|
|
10
|
+
#map = new Map<string, File>();
|
|
11
|
+
|
|
12
|
+
get(key: string): File | null {
|
|
13
|
+
return this.#map.get(key) ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
has(key: string): boolean {
|
|
17
|
+
return this.#map.has(key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
list<T extends ListOptions>(options?: T): ListResult<T> {
|
|
21
|
+
let { cursor, includeMetadata = false, limit = Infinity, prefix } = options ?? {};
|
|
22
|
+
|
|
23
|
+
let files: any[] = [];
|
|
24
|
+
let foundCursor = cursor === undefined;
|
|
25
|
+
let nextCursor: string | undefined;
|
|
26
|
+
|
|
27
|
+
for (let [key, file] of this.#map.entries()) {
|
|
28
|
+
if (foundCursor) {
|
|
29
|
+
if (prefix != null && !key.startsWith(prefix)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (files.length >= limit) {
|
|
34
|
+
nextCursor = files[files.length - 1]?.key;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (includeMetadata) {
|
|
39
|
+
files.push({
|
|
40
|
+
key,
|
|
41
|
+
lastModified: file.lastModified,
|
|
42
|
+
name: file.name,
|
|
43
|
+
size: file.size,
|
|
44
|
+
type: file.type,
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
files.push({ key });
|
|
48
|
+
}
|
|
49
|
+
} else if (key === cursor) {
|
|
50
|
+
foundCursor = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
cursor: nextCursor,
|
|
56
|
+
files,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async put(key: string, file: File): Promise<File> {
|
|
61
|
+
await this.set(key, file);
|
|
62
|
+
return this.get(key)!;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
remove(key: string): void {
|
|
66
|
+
this.#map.delete(key);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async set(key: string, file: File): Promise<void> {
|
|
70
|
+
let buffer = await file.arrayBuffer();
|
|
71
|
+
let newFile = new File([buffer], file.name, {
|
|
72
|
+
lastModified: file.lastModified,
|
|
73
|
+
type: file.type,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.#map.set(key, newFile);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/local.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalFileStorage } from './lib/local-file-storage.ts';
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MemoryFileStorage } from './lib/memory-file-storage.ts';
|