sliftutils 1.4.2 → 1.4.4
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/filehoster.js +4 -0
- package/index.d.ts +105 -0
- package/package.json +72 -70
- package/render-utils/FullscreenModal.tsx +5 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +21 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +14 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +22 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +209 -69
- package/storage/BulkDatabase2/mergeLock.ts +5 -3
- package/storage/BulkDatabase2/streamLog.ts +4 -7
- package/storage/FileFolderAPI.tsx +81 -0
- package/storage/remoteFileServer.d.ts +16 -0
- package/storage/remoteFileServer.ts +439 -0
- package/storage/remoteFileStorage.d.ts +38 -0
- package/storage/remoteFileStorage.ts +236 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { isNode } from "typesafecss";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import type { FileStorage, NestedFileStorage } from "./FileFolderAPI";
|
|
4
|
+
|
|
5
|
+
// Client for remoteFileServer.js: exposes a remote folder as a FileStorage (so BulkDatabase2 runs over
|
|
6
|
+
// the network unchanged). One HTTP round trip per operation. A pure data-level range cache (below)
|
|
7
|
+
// fetches in large aligned chunks and reuses them, because over the network latency dominates — reading
|
|
8
|
+
// 1MB costs about the same as 64KB, so fewer round trips wins.
|
|
9
|
+
|
|
10
|
+
const EMPTY = Buffer.alloc(0);
|
|
11
|
+
|
|
12
|
+
// Over the network, fetch reads in chunks this big and cache them. Big because each request pays a
|
|
13
|
+
// full round trip; over-reading a bit is far cheaper than another round trip.
|
|
14
|
+
const DEFAULT_CHUNK_BYTES = 1024 * 1024;
|
|
15
|
+
const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export type RemoteFileStorageOptions = {
|
|
18
|
+
// Bytes per cached chunk (aligned). Reads coalesce/serve from these.
|
|
19
|
+
chunkBytes?: number;
|
|
20
|
+
// Max total bytes held in the range cache (LRU eviction).
|
|
21
|
+
cacheBytes?: number;
|
|
22
|
+
// Artificial per-request delay, for simulating network latency in tests.
|
|
23
|
+
latencyMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Connection = {
|
|
27
|
+
url: string;
|
|
28
|
+
password: string;
|
|
29
|
+
latencyMs: number;
|
|
30
|
+
agent: https.Agent | undefined;
|
|
31
|
+
cache: RangeCache;
|
|
32
|
+
// Observable stats (handy for tests / diagnostics).
|
|
33
|
+
stats: { requestCount: number; bytesFetched: number };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
// One HTTP request. Node uses the https module (self-signed cert → verification disabled); the browser
|
|
39
|
+
// uses fetch (the user accepts the self-signed cert once). Returns the raw status + body bytes.
|
|
40
|
+
async function httpRequest(conn: Connection, method: string, op: string, params: Record<string, string>, body?: Buffer): Promise<{ status: number; body: Buffer }> {
|
|
41
|
+
if (conn.latencyMs > 0) await sleep(conn.latencyMs);
|
|
42
|
+
conn.stats.requestCount++;
|
|
43
|
+
const qs = new URLSearchParams(params).toString();
|
|
44
|
+
const fullUrl = conn.url.replace(/\/+$/, "") + op + (qs ? "?" + qs : "");
|
|
45
|
+
const headers: Record<string, string> = { authorization: "Bearer " + conn.password };
|
|
46
|
+
if (body) headers["content-type"] = "application/octet-stream";
|
|
47
|
+
|
|
48
|
+
if (isNode()) {
|
|
49
|
+
return await new Promise((resolve, reject) => {
|
|
50
|
+
const u = new URL(fullUrl);
|
|
51
|
+
const req = https.request({
|
|
52
|
+
hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers, agent: conn.agent,
|
|
53
|
+
}, res => {
|
|
54
|
+
const chunks: Buffer[] = [];
|
|
55
|
+
res.on("data", d => chunks.push(d as Buffer));
|
|
56
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
|
|
57
|
+
});
|
|
58
|
+
req.on("error", reject);
|
|
59
|
+
if (body) req.write(body);
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch(fullUrl, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
64
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
65
|
+
return { status: res.status, body: buf };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
69
|
+
const r = await httpRequest(conn, "GET", "/read", { path, start: String(start), end: String(end) });
|
|
70
|
+
if (r.status === 404) return undefined;
|
|
71
|
+
if (r.status !== 200) throw new Error(`remote read failed (${r.status}): ${r.body.toString("utf8").slice(0, 200)}`);
|
|
72
|
+
conn.stats.bytesFetched += r.body.length;
|
|
73
|
+
return r.body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Caches the longest-known prefix of each aligned chunk per path. Safe because every file here is either
|
|
77
|
+
// immutable (bulk files, written once under a unique name) or append-only (stream files): the bytes at a
|
|
78
|
+
// given offset never change, only more are added. So a cached [0, n) of a chunk is always valid; a read
|
|
79
|
+
// that needs MORE than we have refetches (and picks up appended bytes). It never distinguishes file
|
|
80
|
+
// types — it only does range reads.
|
|
81
|
+
class RangeCache {
|
|
82
|
+
private chunks = new Map<string, Buffer>(); // key: path + "" + chunkIndex (insertion-ordered → LRU)
|
|
83
|
+
private bytes = 0;
|
|
84
|
+
constructor(private chunkBytes: number, private budget: number) { }
|
|
85
|
+
|
|
86
|
+
private key(path: string, c: number) { return path + "" + c; }
|
|
87
|
+
private peek(path: string, c: number): Buffer | undefined {
|
|
88
|
+
const k = this.key(path, c);
|
|
89
|
+
const v = this.chunks.get(k);
|
|
90
|
+
if (v) { this.chunks.delete(k); this.chunks.set(k, v); } // bump to most-recent
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
private store(path: string, c: number, buf: Buffer) {
|
|
94
|
+
const k = this.key(path, c);
|
|
95
|
+
const ex = this.chunks.get(k);
|
|
96
|
+
if (ex && ex.length >= buf.length) { this.chunks.delete(k); this.chunks.set(k, ex); return; } // keep longer prefix
|
|
97
|
+
if (ex) this.bytes -= ex.length;
|
|
98
|
+
this.chunks.delete(k);
|
|
99
|
+
this.chunks.set(k, buf);
|
|
100
|
+
this.bytes += buf.length;
|
|
101
|
+
while (this.bytes > this.budget && this.chunks.size > 0) {
|
|
102
|
+
const oldest = this.chunks.keys().next().value as string;
|
|
103
|
+
this.bytes -= (this.chunks.get(oldest) as Buffer).length;
|
|
104
|
+
this.chunks.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
invalidate(path: string) {
|
|
108
|
+
const prefix = path + "";
|
|
109
|
+
for (const k of [...this.chunks.keys()]) {
|
|
110
|
+
if (k.startsWith(prefix)) { this.bytes -= (this.chunks.get(k) as Buffer).length; this.chunks.delete(k); }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
115
|
+
if (end <= start) return EMPTY;
|
|
116
|
+
const CHUNK = this.chunkBytes;
|
|
117
|
+
const firstChunk = Math.floor(start / CHUNK);
|
|
118
|
+
const lastChunk = Math.floor((end - 1) / CHUNK);
|
|
119
|
+
// Find the contiguous span of chunks we don't have enough of, and fetch it in one request.
|
|
120
|
+
let fetchFrom = -1, fetchTo = -1;
|
|
121
|
+
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
122
|
+
const cStart = c * CHUNK;
|
|
123
|
+
const needEnd = Math.min(end, cStart + CHUNK) - cStart;
|
|
124
|
+
const have = this.peek(path, c);
|
|
125
|
+
if (!have || have.length < needEnd) { if (fetchFrom < 0) fetchFrom = c; fetchTo = c; }
|
|
126
|
+
}
|
|
127
|
+
if (fetchFrom >= 0) {
|
|
128
|
+
const bytes = await readRange(conn, path, fetchFrom * CHUNK, (fetchTo + 1) * CHUNK);
|
|
129
|
+
if (bytes === undefined) return undefined; // file missing
|
|
130
|
+
for (let c = fetchFrom; c <= fetchTo; c++) {
|
|
131
|
+
const off = (c - fetchFrom) * CHUNK;
|
|
132
|
+
if (off >= bytes.length) break; // hit EOF — no bytes for this chunk
|
|
133
|
+
this.store(path, c, bytes.subarray(off, Math.min(off + CHUNK, bytes.length)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const parts: Buffer[] = [];
|
|
137
|
+
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
138
|
+
const cStart = c * CHUNK;
|
|
139
|
+
const from = Math.max(start, cStart) - cStart;
|
|
140
|
+
const to = Math.min(end, cStart + CHUNK) - cStart;
|
|
141
|
+
const chunk = this.peek(path, c);
|
|
142
|
+
if (!chunk || chunk.length < to) {
|
|
143
|
+
// File ended before the requested end — return what's available (callers read within EOF).
|
|
144
|
+
if (chunk && chunk.length > from) parts.push(chunk.subarray(from, chunk.length));
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
parts.push(chunk.subarray(from, to));
|
|
148
|
+
}
|
|
149
|
+
return parts.length === 1 ? parts[0] : Buffer.concat(parts);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function makeStorage(conn: Connection, basePath: string): FileStorage {
|
|
154
|
+
const rel = (key: string) => basePath ? basePath + "/" + key : key;
|
|
155
|
+
|
|
156
|
+
const folder: NestedFileStorage = {
|
|
157
|
+
async hasKey(key: string): Promise<boolean> {
|
|
158
|
+
const r = await httpRequest(conn, "GET", "/hasDir", { path: rel(key) });
|
|
159
|
+
if (r.status !== 200) return false;
|
|
160
|
+
return !!JSON.parse(r.body.toString("utf8")).exists;
|
|
161
|
+
},
|
|
162
|
+
async getStorage(key: string): Promise<FileStorage> {
|
|
163
|
+
return makeStorage(conn, rel(key));
|
|
164
|
+
},
|
|
165
|
+
async removeStorage(key: string): Promise<void> {
|
|
166
|
+
await httpRequest(conn, "DELETE", "/removeDir", { path: rel(key) });
|
|
167
|
+
conn.cache.invalidate(rel(key));
|
|
168
|
+
},
|
|
169
|
+
async getKeys(): Promise<string[]> {
|
|
170
|
+
const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: "1" });
|
|
171
|
+
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
172
|
+
return JSON.parse(r.body.toString("utf8"));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
async getInfo(key: string) {
|
|
178
|
+
const r = await httpRequest(conn, "GET", "/info", { path: rel(key) });
|
|
179
|
+
if (r.status === 404) return undefined;
|
|
180
|
+
if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
|
|
181
|
+
return JSON.parse(r.body.toString("utf8")) as { size: number; lastModified: number };
|
|
182
|
+
},
|
|
183
|
+
async get(key: string) {
|
|
184
|
+
const info = await this.getInfo(key);
|
|
185
|
+
if (!info) return undefined;
|
|
186
|
+
return this.getRange(key, { start: 0, end: info.size });
|
|
187
|
+
},
|
|
188
|
+
async getRange(key: string, config: { start: number; end: number }) {
|
|
189
|
+
return conn.cache.getRange(conn, rel(key), config.start, config.end);
|
|
190
|
+
},
|
|
191
|
+
async append(key: string, value: Buffer) {
|
|
192
|
+
const r = await httpRequest(conn, "PUT", "/append", { path: rel(key) }, value);
|
|
193
|
+
if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
|
|
194
|
+
// No invalidation needed: append-only files keep their prefix; a read past it refetches.
|
|
195
|
+
},
|
|
196
|
+
async set(key: string, value: Buffer) {
|
|
197
|
+
const r = await httpRequest(conn, "PUT", "/set", { path: rel(key) }, value);
|
|
198
|
+
if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
|
|
199
|
+
conn.cache.invalidate(rel(key));
|
|
200
|
+
},
|
|
201
|
+
async remove(key: string) {
|
|
202
|
+
await httpRequest(conn, "DELETE", "/remove", { path: rel(key) });
|
|
203
|
+
conn.cache.invalidate(rel(key));
|
|
204
|
+
},
|
|
205
|
+
async getKeys(includeFolders: boolean = false) {
|
|
206
|
+
const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: includeFolders ? "1" : "0" });
|
|
207
|
+
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
208
|
+
return JSON.parse(r.body.toString("utf8"));
|
|
209
|
+
},
|
|
210
|
+
async reset() {
|
|
211
|
+
await httpRequest(conn, "POST", "/reset", { path: basePath });
|
|
212
|
+
conn.cache.invalidate(basePath);
|
|
213
|
+
},
|
|
214
|
+
folder,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & { stats: Connection["stats"] };
|
|
219
|
+
|
|
220
|
+
// Returns a StorageFactory (path -> FileStorage) backed by a remoteFileServer.js instance. Drop-in for
|
|
221
|
+
// getFileStorageNested2 in BulkDatabase2. `password` is the 6-word password the server printed.
|
|
222
|
+
export function getRemoteFileStorage(url: string, password: string, options: RemoteFileStorageOptions = {}): RemoteStorageFactory {
|
|
223
|
+
const conn: Connection = {
|
|
224
|
+
url,
|
|
225
|
+
password,
|
|
226
|
+
latencyMs: options.latencyMs || 0,
|
|
227
|
+
// Self-signed cert: skip verification in Node. (The browser path uses fetch, where the user has
|
|
228
|
+
// already accepted the cert.) The password is what authorizes access.
|
|
229
|
+
agent: isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined,
|
|
230
|
+
cache: new RangeCache(options.chunkBytes || DEFAULT_CHUNK_BYTES, options.cacheBytes || DEFAULT_CACHE_BYTES),
|
|
231
|
+
stats: { requestCount: 0, bytesFetched: 0 },
|
|
232
|
+
};
|
|
233
|
+
const factory = ((path: string) => Promise.resolve(makeStorage(conn, path.replace(/^\/+|\/+$/g, "")))) as RemoteStorageFactory;
|
|
234
|
+
factory.stats = conn.stats;
|
|
235
|
+
return factory;
|
|
236
|
+
}
|