sliftutils 1.7.8 → 1.7.10
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/bin/storageserver.js +4 -0
- package/bundler/bundleEntry.ts +14 -10
- package/bundler/bundler.ts +6 -1
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
- package/index.d.ts +528 -78
- package/misc/https/dns.d.ts +7 -3
- package/misc/https/dns.ts +4 -9
- package/misc/https/hostServer.d.ts +6 -3
- package/misc/https/hostServer.ts +6 -6
- package/package.json +2 -1
- package/spec.txt +77 -42
- package/storage/ArchivesDisk.d.ts +65 -0
- package/storage/ArchivesDisk.ts +365 -0
- package/storage/IArchives.d.ts +95 -1
- package/storage/IArchives.ts +146 -1
- package/storage/backblaze.d.ts +14 -2
- package/storage/backblaze.ts +18 -2
- package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
- package/storage/remoteStorage/ArchivesRemote.ts +63 -63
- package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
- package/storage/remoteStorage/ArchivesUrl.ts +74 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +79 -25
- package/storage/remoteStorage/blobStore.ts +441 -287
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +64 -0
- package/storage/remoteStorage/createArchives.ts +395 -0
- package/storage/remoteStorage/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +21 -0
- package/storage/remoteStorage/remoteConfig.ts +94 -0
- package/storage/remoteStorage/storageController.d.ts +19 -27
- package/storage/remoteStorage/storageController.ts +205 -136
- package/storage/remoteStorage/storageServer.d.ts +11 -0
- package/storage/remoteStorage/storageServer.ts +80 -93
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +41 -0
- package/storage/remoteStorage/storageServerState.d.ts +37 -0
- package/storage/remoteStorage/storageServerState.ts +358 -0
- package/testsite/server.ts +1 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { lazy } from "socket-function/src/caching";
|
|
4
|
+
import { runInfinitePoll } from "socket-function/src/batching";
|
|
5
|
+
import { sort, binarySearchBasic } from "socket-function/src/misc";
|
|
6
|
+
import { IArchives, ArchiveFileInfo, ArchivesConfig, assertValidLastModified } from "./IArchives";
|
|
7
|
+
|
|
8
|
+
// The base file-system IArchives: storage is one-to-one with the file system, every key is exactly
|
|
9
|
+
// one real file under <folder>/files, so the file system itself is the index. File handles are
|
|
10
|
+
// cached and reused, and closed once idle (see FileHandleCache). All operations on a file run in
|
|
11
|
+
// serial, so they can't collide with each other or with handle closing. Used as the disk
|
|
12
|
+
// synchronization source of BlobStore (see remoteStorage/blobStore.ts).
|
|
13
|
+
|
|
14
|
+
const HANDLE_IDLE_TIMEOUT = 1000 * 60;
|
|
15
|
+
const HANDLE_SWEEP_INTERVAL = 1000 * 15;
|
|
16
|
+
|
|
17
|
+
type HandleEntry = {
|
|
18
|
+
filePath: string;
|
|
19
|
+
handle: fs.promises.FileHandle;
|
|
20
|
+
lastUse: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Caches open file handles, closing them once idle for HANDLE_IDLE_TIMEOUT. Instead of one
|
|
24
|
+
// setTimeout per handle, a list sorted by last use is swept periodically (entries are moved via
|
|
25
|
+
// binary search on access, and lastUse values only increase, so touched entries append at the
|
|
26
|
+
// end). Also serializes operations per file: each operation only starts once the previous one on
|
|
27
|
+
// the same file finished, and a handle is never closed while an operation is pending on it.
|
|
28
|
+
class FileHandleCache {
|
|
29
|
+
private entries = new Map<string, HandleEntry>();
|
|
30
|
+
// Sorted by lastUse ascending (least recently used first)
|
|
31
|
+
private lru: HandleEntry[] = [];
|
|
32
|
+
private pending = new Map<string, Promise<void>>();
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
runInfinitePoll(HANDLE_SWEEP_INTERVAL, () => this.sweep());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Runs fnc after every previously scheduled operation on filePath has finished
|
|
39
|
+
public run<T>(filePath: string, fnc: () => Promise<T>): Promise<T> {
|
|
40
|
+
let prev = this.pending.get(filePath) || Promise.resolve();
|
|
41
|
+
let result = prev.then(fnc);
|
|
42
|
+
// The pending chain must never reject, or one failed operation would poison all later ones
|
|
43
|
+
let last = result.then(() => { }, () => { });
|
|
44
|
+
this.pending.set(filePath, last);
|
|
45
|
+
void last.then(() => {
|
|
46
|
+
// If we're still the last pending operation on this file, clear ourselves
|
|
47
|
+
if (this.pending.get(filePath) === last) {
|
|
48
|
+
this.pending.delete(filePath);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Only call inside run() for the same filePath (so opens can't collide with closes)
|
|
55
|
+
public async getHandle(filePath: string, flags: number): Promise<fs.promises.FileHandle> {
|
|
56
|
+
let entry = this.entries.get(filePath);
|
|
57
|
+
if (entry) {
|
|
58
|
+
this.removeFromLRU(entry);
|
|
59
|
+
entry.lastUse = Date.now();
|
|
60
|
+
this.lru.push(entry);
|
|
61
|
+
return entry.handle;
|
|
62
|
+
}
|
|
63
|
+
let handle = await fs.promises.open(filePath, flags);
|
|
64
|
+
entry = { filePath, handle, lastUse: Date.now() };
|
|
65
|
+
this.entries.set(filePath, entry);
|
|
66
|
+
this.lru.push(entry);
|
|
67
|
+
return handle;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only call inside run() for the same filePath
|
|
71
|
+
public async closeNow(filePath: string): Promise<void> {
|
|
72
|
+
let entry = this.entries.get(filePath);
|
|
73
|
+
if (!entry) return;
|
|
74
|
+
this.entries.delete(filePath);
|
|
75
|
+
this.removeFromLRU(entry);
|
|
76
|
+
await entry.handle.close();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private removeFromLRU(entry: HandleEntry) {
|
|
80
|
+
let index = binarySearchBasic(this.lru, x => x.lastUse, entry.lastUse);
|
|
81
|
+
if (index < 0) return;
|
|
82
|
+
// Multiple entries can share a lastUse, so scan for the exact one
|
|
83
|
+
while (index < this.lru.length && this.lru[index].lastUse === entry.lastUse) {
|
|
84
|
+
if (this.lru[index] === entry) {
|
|
85
|
+
this.lru.splice(index, 1);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
index++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async sweep(): Promise<void> {
|
|
93
|
+
let cutoff = Date.now() - HANDLE_IDLE_TIMEOUT;
|
|
94
|
+
let index = 0;
|
|
95
|
+
while (index < this.lru.length && this.lru[index].lastUse <= cutoff) {
|
|
96
|
+
let entry = this.lru[index];
|
|
97
|
+
// Can't close a handle with a pending operation; it'll be swept after it finishes
|
|
98
|
+
if (this.pending.has(entry.filePath)) {
|
|
99
|
+
index++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
this.lru.splice(index, 1);
|
|
103
|
+
this.entries.delete(entry.filePath);
|
|
104
|
+
await entry.handle.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class ArchivesDisk implements IArchives {
|
|
110
|
+
constructor(private folder: string) { }
|
|
111
|
+
|
|
112
|
+
private filesDir = path.join(this.folder, "files");
|
|
113
|
+
private uploadsDir = path.join(this.folder, "uploads");
|
|
114
|
+
private handles = new FileHandleCache();
|
|
115
|
+
private largeUploads = new Map<string, { tmpPath: string }>();
|
|
116
|
+
private nextLargeUploadId = 1;
|
|
117
|
+
|
|
118
|
+
public init = lazy(async () => {
|
|
119
|
+
await fs.promises.mkdir(this.filesDir, { recursive: true });
|
|
120
|
+
await fs.promises.mkdir(this.uploadsDir, { recursive: true });
|
|
121
|
+
// Uploads don't survive restarts (the uploader streams into them), so old ones are garbage
|
|
122
|
+
for (let file of await fs.promises.readdir(this.uploadsDir)) {
|
|
123
|
+
await fs.promises.unlink(path.join(this.uploadsDir, file));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
public getDebugName() {
|
|
128
|
+
return `disk/${this.folder}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public async getConfig(): Promise<ArchivesConfig> {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private filePath(key: string): string {
|
|
136
|
+
let result = path.join(this.filesDir, key);
|
|
137
|
+
if (!result.startsWith(this.filesDir + path.sep)) {
|
|
138
|
+
throw new Error(`Invalid key ${JSON.stringify(key.slice(0, 200))}, it escapes the store folder`);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public async set(key: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
144
|
+
await this.init();
|
|
145
|
+
let lastModified = config?.lastModified;
|
|
146
|
+
if (lastModified) {
|
|
147
|
+
assertValidLastModified(lastModified);
|
|
148
|
+
}
|
|
149
|
+
let filePath = this.filePath(key);
|
|
150
|
+
await this.handles.run(filePath, async () => {
|
|
151
|
+
if (lastModified) {
|
|
152
|
+
let existing = await statOrUndefined(filePath);
|
|
153
|
+
// An older write never overwrites a newer one (see IArchives.set)
|
|
154
|
+
if (existing && lastModified < existing.mtimeMs) return;
|
|
155
|
+
}
|
|
156
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
157
|
+
let handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR | fs.constants.O_CREAT);
|
|
158
|
+
await handle.truncate(0);
|
|
159
|
+
await handle.write(data, 0, data.length, 0);
|
|
160
|
+
if (lastModified) {
|
|
161
|
+
await handle.utimes(new Date(lastModified), new Date(lastModified));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public async del(key: string): Promise<void> {
|
|
167
|
+
await this.init();
|
|
168
|
+
let filePath = this.filePath(key);
|
|
169
|
+
await this.handles.run(filePath, async () => {
|
|
170
|
+
await this.handles.closeNow(filePath);
|
|
171
|
+
try {
|
|
172
|
+
await fs.promises.unlink(filePath);
|
|
173
|
+
} catch (e: any) {
|
|
174
|
+
if (e.code !== "ENOENT") throw e;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public async get(key: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
180
|
+
let result = await this.get2(key, config);
|
|
181
|
+
return result && result.data || undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
|
|
185
|
+
await this.init();
|
|
186
|
+
let range = config?.range;
|
|
187
|
+
let filePath = this.filePath(key);
|
|
188
|
+
return await this.handles.run(filePath, async () => {
|
|
189
|
+
let handle: fs.promises.FileHandle;
|
|
190
|
+
try {
|
|
191
|
+
handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR);
|
|
192
|
+
} catch (e: any) {
|
|
193
|
+
if (e.code === "ENOENT") return undefined;
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
let stats = await handle.stat();
|
|
197
|
+
let size = stats.size;
|
|
198
|
+
let start = range && Math.min(range.start, size) || 0;
|
|
199
|
+
let end = range && Math.min(range.end, size) || size;
|
|
200
|
+
if (end <= start) return { data: Buffer.alloc(0), writeTime: stats.mtimeMs };
|
|
201
|
+
let buffer = Buffer.alloc(end - start);
|
|
202
|
+
let { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
|
203
|
+
if (bytesRead !== buffer.length) {
|
|
204
|
+
throw new Error(`Expected ${buffer.length} bytes at ${filePath}:${start}, read ${bytesRead}`);
|
|
205
|
+
}
|
|
206
|
+
return { data: buffer, writeTime: stats.mtimeMs };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
211
|
+
await this.init();
|
|
212
|
+
let filePath = this.filePath(key);
|
|
213
|
+
return await this.handles.run(filePath, async () => {
|
|
214
|
+
let stats = await statOrUndefined(filePath);
|
|
215
|
+
if (!stats || !stats.isFile()) return undefined;
|
|
216
|
+
return { writeTime: stats.mtimeMs, size: stats.size };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
221
|
+
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
225
|
+
await this.init();
|
|
226
|
+
let infos = new Map<string, ArchiveFileInfo>();
|
|
227
|
+
await this.collectFiles("", prefix, infos);
|
|
228
|
+
let files = Array.from(infos.values());
|
|
229
|
+
files = applyFindInfoShape(files, prefix, config);
|
|
230
|
+
sort(files, x => x.path);
|
|
231
|
+
return files;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// relDir is "" or ends with "/". Only descends into directories that can still match the prefix.
|
|
235
|
+
private async collectFiles(relDir: string, prefix: string, infos: Map<string, ArchiveFileInfo>): Promise<void> {
|
|
236
|
+
let entries: fs.Dirent[];
|
|
237
|
+
try {
|
|
238
|
+
entries = await fs.promises.readdir(path.join(this.filesDir, relDir), { withFileTypes: true });
|
|
239
|
+
} catch (e: any) {
|
|
240
|
+
if (e.code === "ENOENT") return;
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
243
|
+
for (let entry of entries) {
|
|
244
|
+
let relPath = relDir + entry.name;
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
let dirPath = relPath + "/";
|
|
247
|
+
if (dirPath.startsWith(prefix) || prefix.startsWith(dirPath)) {
|
|
248
|
+
await this.collectFiles(dirPath, prefix, infos);
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!entry.isFile()) continue;
|
|
253
|
+
if (!relPath.startsWith(prefix)) continue;
|
|
254
|
+
let stats = await statOrUndefined(path.join(this.filesDir, relPath));
|
|
255
|
+
// Deleted while we were walking
|
|
256
|
+
if (!stats) continue;
|
|
257
|
+
infos.set(relPath, { path: relPath, createTime: stats.mtimeMs, size: stats.size });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
262
|
+
let id = await this.startLargeUpload();
|
|
263
|
+
try {
|
|
264
|
+
while (true) {
|
|
265
|
+
let data = await config.getNextData();
|
|
266
|
+
if (!data) break;
|
|
267
|
+
await this.appendLargeUpload(id, data);
|
|
268
|
+
}
|
|
269
|
+
await this.finishLargeUpload(id, config.path);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
await this.cancelLargeUpload(id);
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Large files stream into their own file under uploads/, then move into place on finish. No
|
|
277
|
+
// handle is opened until the first append actually happens.
|
|
278
|
+
public async startLargeUpload(): Promise<string> {
|
|
279
|
+
await this.init();
|
|
280
|
+
let id = `${Date.now()}_${this.nextLargeUploadId++}`;
|
|
281
|
+
this.largeUploads.set(id, { tmpPath: path.join(this.uploadsDir, `upload_${id}.tmp`) });
|
|
282
|
+
return id;
|
|
283
|
+
}
|
|
284
|
+
public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
|
|
285
|
+
let upload = this.largeUploads.get(id);
|
|
286
|
+
if (!upload) throw new Error(`Unknown large upload ${id}`);
|
|
287
|
+
const tmpPath = upload.tmpPath;
|
|
288
|
+
await this.handles.run(tmpPath, async () => {
|
|
289
|
+
let handle = await this.handles.getHandle(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
290
|
+
await handle.write(data, 0, data.length);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
public async finishLargeUpload(id: string, key: string): Promise<void> {
|
|
294
|
+
let upload = this.largeUploads.get(id);
|
|
295
|
+
if (!upload) throw new Error(`Unknown large upload ${id}`);
|
|
296
|
+
const tmpPath = upload.tmpPath;
|
|
297
|
+
this.largeUploads.delete(id);
|
|
298
|
+
let filePath = this.filePath(key);
|
|
299
|
+
await this.handles.run(tmpPath, () => this.handles.closeNow(tmpPath));
|
|
300
|
+
await this.handles.run(filePath, async () => {
|
|
301
|
+
// Close any cached handle to the file we're replacing, so later reads reopen the new file
|
|
302
|
+
await this.handles.closeNow(filePath);
|
|
303
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
304
|
+
try {
|
|
305
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
306
|
+
} catch (e: any) {
|
|
307
|
+
if (e.code !== "ENOENT") throw e;
|
|
308
|
+
// Nothing was ever appended, so the upload file was never created
|
|
309
|
+
await fs.promises.writeFile(filePath, Buffer.alloc(0));
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
public async cancelLargeUpload(id: string): Promise<void> {
|
|
314
|
+
let upload = this.largeUploads.get(id);
|
|
315
|
+
if (!upload) return;
|
|
316
|
+
const tmpPath = upload.tmpPath;
|
|
317
|
+
this.largeUploads.delete(id);
|
|
318
|
+
await this.handles.run(tmpPath, async () => {
|
|
319
|
+
await this.handles.closeNow(tmpPath);
|
|
320
|
+
try {
|
|
321
|
+
await fs.promises.unlink(tmpPath);
|
|
322
|
+
} catch (e: any) {
|
|
323
|
+
if (e.code !== "ENOENT") throw e;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
public async getURL(path: string): Promise<string> {
|
|
329
|
+
throw new Error(`getURL is not supported for disk archives (${this.getDebugName()}, path ${path})`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function statOrUndefined(filePath: string): Promise<fs.Stats | undefined> {
|
|
334
|
+
try {
|
|
335
|
+
return await fs.promises.stat(filePath);
|
|
336
|
+
} catch (e: any) {
|
|
337
|
+
if (e.code === "ENOENT") return undefined;
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The folders/shallow post-processing shared by findInfo implementations that list flat files
|
|
343
|
+
// (used by ArchivesDisk on the raw disk walk, and by BlobStore on its index).
|
|
344
|
+
export function applyFindInfoShape(files: ArchiveFileInfo[], prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): ArchiveFileInfo[] {
|
|
345
|
+
if (config?.type === "folders") {
|
|
346
|
+
let folders = new Map<string, ArchiveFileInfo>();
|
|
347
|
+
for (let file of files) {
|
|
348
|
+
let rest = file.path.slice(prefix.length);
|
|
349
|
+
let restParts = rest.split("/");
|
|
350
|
+
if (restParts.length < 2) continue;
|
|
351
|
+
let folder: string;
|
|
352
|
+
if (config.shallow) {
|
|
353
|
+
folder = prefix + restParts[0];
|
|
354
|
+
} else {
|
|
355
|
+
folder = file.path.split("/").slice(0, -1).join("/");
|
|
356
|
+
}
|
|
357
|
+
folders.set(folder, { path: folder, createTime: file.createTime, size: file.size });
|
|
358
|
+
}
|
|
359
|
+
return Array.from(folders.values());
|
|
360
|
+
}
|
|
361
|
+
if (config?.shallow) {
|
|
362
|
+
return files.filter(file => !file.path.slice(prefix.length).includes("/"));
|
|
363
|
+
}
|
|
364
|
+
return files;
|
|
365
|
+
}
|
package/storage/IArchives.d.ts
CHANGED
|
@@ -1,10 +1,77 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
+
export declare const MAX_LAST_MODIFIED_FUTURE: number;
|
|
4
|
+
export declare function assertValidLastModified(lastModified: number): void;
|
|
5
|
+
export type RemoteConfig = {
|
|
6
|
+
version?: number;
|
|
7
|
+
sources: RemoteConfigBase[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
string arguments will be a url, looking like:
|
|
11
|
+
https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
|
|
12
|
+
https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
|
|
13
|
+
- These map to { url }, with the type inferred from the url
|
|
14
|
+
- Hosted urls are /file/<account>/<bucketName>/..., backblaze urls are /file/<bucketName>/...
|
|
15
|
+
|
|
16
|
+
NOTE: If we do not have right access to these, then it becomes a read-only IArchives, where we solely read using the url form (which might throw due to not having access as well). UNLESS Our configuration explicitly has public: false, in which case, we don't even hit the URL and we throw on access.
|
|
17
|
+
|
|
18
|
+
NOTE: If we're in the browser, we should allow downloading the files via the URL form (if it's a public bucket), however, we won't allow writing, because their servers do not allow secure browser writes.
|
|
19
|
+
*/
|
|
20
|
+
export type RemoteConfigBase = string | HostedConfig | BackblazeConfig;
|
|
21
|
+
export type CommonConfig = {
|
|
22
|
+
/** The default options for the first config in a list is DEFAULT_BASE_SYNC_OPTIONS. The rest default to DEFAULT_SYNC_OPTIONS. */
|
|
23
|
+
syncOptions?: SyncOptions;
|
|
24
|
+
};
|
|
25
|
+
export type HostedConfig = CommonConfig & {
|
|
26
|
+
type: "remote";
|
|
27
|
+
url: string;
|
|
28
|
+
accountName?: string;
|
|
29
|
+
public?: boolean;
|
|
30
|
+
fast?: boolean;
|
|
31
|
+
writeDelay?: number;
|
|
32
|
+
rawDisk?: boolean;
|
|
33
|
+
immutable?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type BackblazeConfig = CommonConfig & {
|
|
36
|
+
type: "backblaze";
|
|
37
|
+
url: string;
|
|
38
|
+
bucketName: string;
|
|
39
|
+
public?: boolean;
|
|
40
|
+
immutable?: boolean;
|
|
41
|
+
};
|
|
42
|
+
export declare const DEFAULT_BASE_SYNC_OPTIONS: SyncOptions;
|
|
43
|
+
export declare const DEFAULT_SYNC_OPTIONS: SyncOptions;
|
|
3
44
|
export type ArchiveFileInfo = {
|
|
4
45
|
path: string;
|
|
5
46
|
createTime: number;
|
|
6
47
|
size: number;
|
|
7
48
|
};
|
|
49
|
+
export type ArchivesConfig = {
|
|
50
|
+
supportsChangesAfter?: boolean;
|
|
51
|
+
};
|
|
52
|
+
export type SyncOptions = {
|
|
53
|
+
copyFiles?: boolean;
|
|
54
|
+
noWriteBack?: boolean;
|
|
55
|
+
cacheReads?: boolean;
|
|
56
|
+
validWindow: [number, number];
|
|
57
|
+
required?: boolean;
|
|
58
|
+
};
|
|
59
|
+
export type ArchivesSource = {
|
|
60
|
+
source: IArchives;
|
|
61
|
+
options: SyncOptions;
|
|
62
|
+
};
|
|
63
|
+
export type ArchivesSyncSourceStatus = {
|
|
64
|
+
debugName: string;
|
|
65
|
+
options: SyncOptions;
|
|
66
|
+
supportsChangesAfter: boolean;
|
|
67
|
+
initialScanComplete: boolean;
|
|
68
|
+
scannedCount: number;
|
|
69
|
+
};
|
|
70
|
+
export type ArchivesSyncStatus = {
|
|
71
|
+
allScansComplete: boolean;
|
|
72
|
+
indexSize: number;
|
|
73
|
+
sources: ArchivesSyncSourceStatus[];
|
|
74
|
+
};
|
|
8
75
|
export interface IArchives {
|
|
9
76
|
getDebugName(): string;
|
|
10
77
|
get(fileName: string, config?: {
|
|
@@ -13,13 +80,31 @@ export interface IArchives {
|
|
|
13
80
|
end: number;
|
|
14
81
|
};
|
|
15
82
|
}): Promise<Buffer | undefined>;
|
|
16
|
-
|
|
83
|
+
/** Like get, but also returns the last-write time of the file. get just calls get2. */
|
|
84
|
+
get2(fileName: string, config?: {
|
|
85
|
+
range?: {
|
|
86
|
+
start: number;
|
|
87
|
+
end: number;
|
|
88
|
+
};
|
|
89
|
+
}): Promise<{
|
|
90
|
+
data: Buffer;
|
|
91
|
+
writeTime: number;
|
|
92
|
+
} | undefined>;
|
|
93
|
+
/**
|
|
94
|
+
* lastModified stamps the write with that last-write time instead of now. If it is OLDER than
|
|
95
|
+
* the file's current last-write time the write no-ops (so delayed / synchronized writes can
|
|
96
|
+
* never clobber newer data). Times more than 15 minutes in the future are rejected.
|
|
97
|
+
*/
|
|
98
|
+
set(fileName: string, data: Buffer, config?: {
|
|
99
|
+
lastModified?: number;
|
|
100
|
+
}): Promise<void>;
|
|
17
101
|
del(fileName: string): Promise<void>;
|
|
18
102
|
/** Streams a file too large to hold in memory. getNextData returns undefined when done. */
|
|
19
103
|
setLargeFile(config: {
|
|
20
104
|
path: string;
|
|
21
105
|
getNextData(): Promise<Buffer | undefined>;
|
|
22
106
|
}): Promise<void>;
|
|
107
|
+
/** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. */
|
|
23
108
|
getInfo(fileName: string): Promise<{
|
|
24
109
|
writeTime: number;
|
|
25
110
|
size: number;
|
|
@@ -34,4 +119,13 @@ export interface IArchives {
|
|
|
34
119
|
}): Promise<ArchiveFileInfo[]>;
|
|
35
120
|
/** Only works for public buckets (private buckets are API-access only). */
|
|
36
121
|
getURL(path: string): Promise<string>;
|
|
122
|
+
/** The bucket's configuration, which tells whether the optional functions are supported. */
|
|
123
|
+
getConfig(): Promise<ArchivesConfig>;
|
|
124
|
+
/**
|
|
125
|
+
* All files changed after the given time. Only exists when getConfig().supportsChangesAfter;
|
|
126
|
+
* backed by an index, so it is fast (unlike a full findInfo scan). Deletions are not reported.
|
|
127
|
+
*/
|
|
128
|
+
getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
|
|
129
|
+
/** Synchronization introspection, for backends that synchronize from sources (see BlobStore). */
|
|
130
|
+
getSyncStatus?(): Promise<ArchivesSyncStatus>;
|
|
37
131
|
}
|
package/storage/IArchives.ts
CHANGED
|
@@ -3,18 +3,163 @@ module.allowclient = true;
|
|
|
3
3
|
// The important operations of an archive bucket (extracted from ArchivesBackblaze), so other
|
|
4
4
|
// backends (e.g. our own remote storage server) can be used interchangeably.
|
|
5
5
|
|
|
6
|
+
// A write may not be stamped more than this far in the future, or clock skew between machines
|
|
7
|
+
// would let a bad timestamp block writes for a long time.
|
|
8
|
+
export const MAX_LAST_MODIFIED_FUTURE = 15 * 60 * 1000;
|
|
9
|
+
export function assertValidLastModified(lastModified: number): void {
|
|
10
|
+
let max = Date.now() + MAX_LAST_MODIFIED_FUTURE;
|
|
11
|
+
if (lastModified > max) {
|
|
12
|
+
throw new Error(`lastModified is too far in the future: ${lastModified} > ${max} (now + 15 minutes)`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export type RemoteConfig = {
|
|
18
|
+
// NOTE: Version is used when updating the configuration. The newer version is always taken. A missing version counts as version -1.
|
|
19
|
+
version?: number;
|
|
20
|
+
sources: RemoteConfigBase[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
string arguments will be a url, looking like:
|
|
25
|
+
https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
|
|
26
|
+
https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
|
|
27
|
+
- These map to { url }, with the type inferred from the url
|
|
28
|
+
- Hosted urls are /file/<account>/<bucketName>/..., backblaze urls are /file/<bucketName>/...
|
|
29
|
+
|
|
30
|
+
NOTE: If we do not have right access to these, then it becomes a read-only IArchives, where we solely read using the url form (which might throw due to not having access as well). UNLESS Our configuration explicitly has public: false, in which case, we don't even hit the URL and we throw on access.
|
|
31
|
+
|
|
32
|
+
NOTE: If we're in the browser, we should allow downloading the files via the URL form (if it's a public bucket), however, we won't allow writing, because their servers do not allow secure browser writes.
|
|
33
|
+
*/
|
|
34
|
+
export type RemoteConfigBase = string | HostedConfig | BackblazeConfig;
|
|
35
|
+
|
|
36
|
+
export type CommonConfig = {
|
|
37
|
+
/** The default options for the first config in a list is DEFAULT_BASE_SYNC_OPTIONS. The rest default to DEFAULT_SYNC_OPTIONS. */
|
|
38
|
+
syncOptions?: SyncOptions;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type HostedConfig = CommonConfig & {
|
|
42
|
+
type: "remote";
|
|
43
|
+
|
|
44
|
+
// Ex: https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
|
|
45
|
+
// NOTE: The account and bucket name are obtained from the URL.
|
|
46
|
+
url: string;
|
|
47
|
+
|
|
48
|
+
// NOTE: Authentication is handled by cert.ts, via having your machine trusted to access this account.
|
|
49
|
+
accountName?: string;
|
|
50
|
+
|
|
51
|
+
public?: boolean;
|
|
52
|
+
// Fast mode: the server acknowledges writes once they are in memory, flushing to disk after
|
|
53
|
+
// writeDelay (default 5 minutes) and coalescing writes to the same file. A server crash loses
|
|
54
|
+
// writes that haven't flushed yet.
|
|
55
|
+
fast?: boolean;
|
|
56
|
+
writeDelay?: number;
|
|
57
|
+
// The bucket is served straight from the server's disk, with no index — so no fast writes and
|
|
58
|
+
// no getChangesAfter/getSyncStatus.
|
|
59
|
+
rawDisk?: boolean;
|
|
60
|
+
// Writes to paths that already exist are disallowed (deletes still work).
|
|
61
|
+
immutable?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type BackblazeConfig = CommonConfig & {
|
|
65
|
+
type: "backblaze";
|
|
66
|
+
// Ex: https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
|
|
67
|
+
// NOTE: The bucket name is obtained from the URL.
|
|
68
|
+
url: string;
|
|
69
|
+
// Public buckets are served over plain HTTPS GETs (getURL). Private buckets are API-access only.
|
|
70
|
+
bucketName: string;
|
|
71
|
+
public?: boolean;
|
|
72
|
+
// NOTE: This isn't enforced on the backblaze level, so this is just a client-side guarantee. This can change how we cache files.
|
|
73
|
+
// - Backblaze does support immutability. However, apparently, once we enable it on a bucket, we can't disable it, which is really bad, as it means if our code could ever enable it and we accidentally enable it on an important bucket, we essentially just bricked that bucket. So we should never write any code that ever tries to use backblaze to make things immutable.
|
|
74
|
+
immutable?: boolean;
|
|
75
|
+
|
|
76
|
+
// NOTE: We will access the api key from getSecret, see backblaze.ts for the specific keys.
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
export const DEFAULT_BASE_SYNC_OPTIONS: SyncOptions = {
|
|
81
|
+
cacheReads: true,
|
|
82
|
+
required: true,
|
|
83
|
+
validWindow: [0, Number.MAX_SAFE_INTEGER],
|
|
84
|
+
};
|
|
85
|
+
export const DEFAULT_SYNC_OPTIONS: SyncOptions = {
|
|
86
|
+
validWindow: [0, Number.MAX_SAFE_INTEGER],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
// createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as
|
|
92
|
+
// getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date:
|
|
93
|
+
// each write stamps a fresh timestamp on the current version, so both fields are just "when the
|
|
94
|
+
// bytes served by get() were most recently written".
|
|
6
95
|
export type ArchiveFileInfo = { path: string; createTime: number; size: number };
|
|
7
96
|
|
|
97
|
+
export type ArchivesConfig = {
|
|
98
|
+
// Whether getChangesAfter is implemented (fast change polling, instead of full rescans)
|
|
99
|
+
supportsChangesAfter?: boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// How a synchronization source behaves (see BlobStore, which synchronizes an index + local cache
|
|
103
|
+
// from a list of { source, options }).
|
|
104
|
+
export type SyncOptions = {
|
|
105
|
+
// After the source's metadata is copied into the index, also copy the file contents over (into
|
|
106
|
+
// the cacheReads sources), preserving their modified times.
|
|
107
|
+
copyFiles?: boolean;
|
|
108
|
+
// Writes are NOT written to this source (by default every source receives writes).
|
|
109
|
+
noWriteBack?: boolean;
|
|
110
|
+
// If a read wasn't served by this source, write the data back to it (using it as a cache), and
|
|
111
|
+
// update the index so it becomes the new source. Set for the local disk source.
|
|
112
|
+
cacheReads?: boolean;
|
|
113
|
+
// Ignore values with write times outside [start, end].
|
|
114
|
+
validWindow: [number, number];
|
|
115
|
+
// If a file isn't in the index and this source hasn't finished its initial scan, check the
|
|
116
|
+
// source directly before declaring the file nonexistent. Set for the disk source.
|
|
117
|
+
required?: boolean;
|
|
118
|
+
};
|
|
119
|
+
export type ArchivesSource = { source: IArchives; options: SyncOptions };
|
|
120
|
+
|
|
121
|
+
export type ArchivesSyncSourceStatus = {
|
|
122
|
+
debugName: string;
|
|
123
|
+
options: SyncOptions;
|
|
124
|
+
supportsChangesAfter: boolean;
|
|
125
|
+
initialScanComplete: boolean;
|
|
126
|
+
// Files seen in this source's scans / change polls so far
|
|
127
|
+
scannedCount: number;
|
|
128
|
+
};
|
|
129
|
+
export type ArchivesSyncStatus = {
|
|
130
|
+
allScansComplete: boolean;
|
|
131
|
+
// Number of files in the index
|
|
132
|
+
indexSize: number;
|
|
133
|
+
sources: ArchivesSyncSourceStatus[];
|
|
134
|
+
};
|
|
135
|
+
|
|
8
136
|
export interface IArchives {
|
|
9
137
|
getDebugName(): string;
|
|
10
138
|
get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
|
|
11
|
-
|
|
139
|
+
/** Like get, but also returns the last-write time of the file. get just calls get2. */
|
|
140
|
+
get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined>;
|
|
141
|
+
/**
|
|
142
|
+
* lastModified stamps the write with that last-write time instead of now. If it is OLDER than
|
|
143
|
+
* the file's current last-write time the write no-ops (so delayed / synchronized writes can
|
|
144
|
+
* never clobber newer data). Times more than 15 minutes in the future are rejected.
|
|
145
|
+
*/
|
|
146
|
+
set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void>;
|
|
12
147
|
del(fileName: string): Promise<void>;
|
|
13
148
|
/** Streams a file too large to hold in memory. getNextData returns undefined when done. */
|
|
14
149
|
setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void>;
|
|
150
|
+
/** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. */
|
|
15
151
|
getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
|
|
16
152
|
find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]>;
|
|
17
153
|
findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
|
|
18
154
|
/** Only works for public buckets (private buckets are API-access only). */
|
|
19
155
|
getURL(path: string): Promise<string>;
|
|
156
|
+
/** The bucket's configuration, which tells whether the optional functions are supported. */
|
|
157
|
+
getConfig(): Promise<ArchivesConfig>;
|
|
158
|
+
/**
|
|
159
|
+
* All files changed after the given time. Only exists when getConfig().supportsChangesAfter;
|
|
160
|
+
* backed by an index, so it is fast (unlike a full findInfo scan). Deletions are not reported.
|
|
161
|
+
*/
|
|
162
|
+
getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
|
|
163
|
+
/** Synchronization introspection, for backends that synchronize from sources (see BlobStore). */
|
|
164
|
+
getSyncStatus?(): Promise<ArchivesSyncStatus>;
|
|
20
165
|
}
|
package/storage/backblaze.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { IArchives } from "./IArchives";
|
|
3
|
+
import { IArchives, ArchivesConfig } from "./IArchives";
|
|
4
4
|
export declare class ArchivesBackblaze implements IArchives {
|
|
5
5
|
private config;
|
|
6
6
|
constructor(config: {
|
|
@@ -25,7 +25,19 @@ export declare class ArchivesBackblaze implements IArchives {
|
|
|
25
25
|
};
|
|
26
26
|
retryCount?: number;
|
|
27
27
|
}): Promise<Buffer | undefined>;
|
|
28
|
-
|
|
28
|
+
get2(fileName: string, config?: {
|
|
29
|
+
range?: {
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
};
|
|
33
|
+
}): Promise<{
|
|
34
|
+
data: Buffer;
|
|
35
|
+
writeTime: number;
|
|
36
|
+
} | undefined>;
|
|
37
|
+
getConfig(): Promise<ArchivesConfig>;
|
|
38
|
+
set(fileName: string, data: Buffer, config?: {
|
|
39
|
+
lastModified?: number;
|
|
40
|
+
}): Promise<void>;
|
|
29
41
|
del(fileName: string): Promise<void>;
|
|
30
42
|
setLargeFile(config: {
|
|
31
43
|
path: string;
|