sliftutils 1.4.12 → 1.4.14
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/index.d.ts +10 -0
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +7 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +43 -5
- package/storage/FileFolderAPI.d.ts +2 -0
- package/storage/FileFolderAPI.tsx +9 -3
- package/storage/remoteFileServer.ts +81 -1
- package/storage/remoteFileStorage.d.ts +1 -0
- package/storage/remoteFileStorage.ts +205 -52
package/index.d.ts
CHANGED
|
@@ -1014,6 +1014,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1014
1014
|
column: string;
|
|
1015
1015
|
byteSize: number;
|
|
1016
1016
|
}[]>;
|
|
1017
|
+
getKeyStats(): Promise<{
|
|
1018
|
+
rawKeys: number;
|
|
1019
|
+
finalKeys: number;
|
|
1020
|
+
wastedKeys: number;
|
|
1021
|
+
duplication: number;
|
|
1022
|
+
readers: number;
|
|
1023
|
+
}>;
|
|
1017
1024
|
getReaderInfo(): Promise<{
|
|
1018
1025
|
rowCount: number;
|
|
1019
1026
|
totalBytes: number;
|
|
@@ -1364,6 +1371,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1364
1371
|
export type DirectoryWrapper = {
|
|
1365
1372
|
readonly kind: "directory";
|
|
1366
1373
|
readonly name: string;
|
|
1374
|
+
readonly fullPath?: string;
|
|
1367
1375
|
removeEntry(key: string, options?: {
|
|
1368
1376
|
recursive?: boolean;
|
|
1369
1377
|
}): Promise<void>;
|
|
@@ -1403,6 +1411,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1403
1411
|
constructor(rootPath: string);
|
|
1404
1412
|
readonly kind: "directory";
|
|
1405
1413
|
get name(): string;
|
|
1414
|
+
get fullPath(): string;
|
|
1406
1415
|
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1407
1416
|
removeEntry(key: string, options?: {
|
|
1408
1417
|
recursive?: boolean;
|
|
@@ -1850,6 +1859,7 @@ declare module "sliftutils/storage/remoteFileStorage" {
|
|
|
1850
1859
|
export type RemoteOptions = {
|
|
1851
1860
|
chunkBytes?: number;
|
|
1852
1861
|
cacheBytes?: number;
|
|
1862
|
+
maxFetchBytes?: number;
|
|
1853
1863
|
latencyMs?: number;
|
|
1854
1864
|
stats?: {
|
|
1855
1865
|
requestCount: number;
|
package/package.json
CHANGED
|
@@ -117,6 +117,13 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
117
117
|
column: string;
|
|
118
118
|
byteSize: number;
|
|
119
119
|
}[]>;
|
|
120
|
+
getKeyStats(): Promise<{
|
|
121
|
+
rawKeys: number;
|
|
122
|
+
finalKeys: number;
|
|
123
|
+
wastedKeys: number;
|
|
124
|
+
duplication: number;
|
|
125
|
+
readers: number;
|
|
126
|
+
}>;
|
|
120
127
|
getReaderInfo(): Promise<{
|
|
121
128
|
rowCount: number;
|
|
122
129
|
totalBytes: number;
|
|
@@ -637,7 +637,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
637
637
|
syncBroadcastSeal(this.name);
|
|
638
638
|
this.streamFileName = undefined;
|
|
639
639
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
640
|
-
|
|
640
|
+
// compact() merges every file on disk, so nothing older survives outside it — tombstones for
|
|
641
|
+
// fully-deleted keys can be dropped rather than carried into a fresh carry stream.
|
|
642
|
+
if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
|
|
641
643
|
} finally {
|
|
642
644
|
releaseMergeLock(this.name, writerId);
|
|
643
645
|
}
|
|
@@ -662,7 +664,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
662
664
|
const selStream = streamFiles.filter(f =>
|
|
663
665
|
f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
|
|
664
666
|
if (selBulk.length + selStream.length < 2) return;
|
|
665
|
-
|
|
667
|
+
// timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
|
|
668
|
+
// merge, so no older set survives outside it and surviving tombstones can be dropped.
|
|
669
|
+
await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
|
|
666
670
|
}
|
|
667
671
|
|
|
668
672
|
// Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
|
|
@@ -804,7 +808,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
804
808
|
// merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
|
|
805
809
|
// only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
|
|
806
810
|
// didn't change while we read it. Returns whether it produced anything.
|
|
807
|
-
|
|
811
|
+
// `includesOldest` means this merge consumes every file at or before its time range — there is no
|
|
812
|
+
// file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
|
|
813
|
+
// outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
|
|
814
|
+
// nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
|
|
815
|
+
// merge that reaches time 0 are the cases where this holds.)
|
|
816
|
+
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
|
|
808
817
|
const storage = await this.storage();
|
|
809
818
|
const timestamp = nextFileTime();
|
|
810
819
|
const now = Date.now();
|
|
@@ -837,7 +846,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
837
846
|
newNames.push(name);
|
|
838
847
|
}
|
|
839
848
|
}
|
|
840
|
-
if
|
|
849
|
+
// Carry surviving tombstones forward only if older files exist outside this merge that they still
|
|
850
|
+
// need to suppress; when this merge includes the oldest data there's nothing older to suppress.
|
|
851
|
+
const carriedDeletes = includesOldest ? 0 : deletes.size;
|
|
852
|
+
if (carriedDeletes) {
|
|
841
853
|
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
842
854
|
await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
|
|
843
855
|
}
|
|
@@ -849,7 +861,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
849
861
|
}
|
|
850
862
|
|
|
851
863
|
this.resetReader();
|
|
852
|
-
return newNames.length > 0 ||
|
|
864
|
+
return newNames.length > 0 || carriedDeletes > 0;
|
|
853
865
|
}
|
|
854
866
|
|
|
855
867
|
// A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
|
|
@@ -1177,6 +1189,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1177
1189
|
return reader.columns;
|
|
1178
1190
|
}
|
|
1179
1191
|
|
|
1192
|
+
// Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
|
|
1193
|
+
// would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
|
|
1194
|
+
// tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
|
|
1195
|
+
// keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
|
|
1196
|
+
// this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
|
|
1197
|
+
// fragmented, compaction would shrink it).
|
|
1198
|
+
public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
|
|
1199
|
+
const reader = await this.reader();
|
|
1200
|
+
const rawKeys = reader.rawKeyCount;
|
|
1201
|
+
const finalKeys = reader.keys.length;
|
|
1202
|
+
return {
|
|
1203
|
+
rawKeys,
|
|
1204
|
+
finalKeys,
|
|
1205
|
+
wastedKeys: rawKeys - finalKeys,
|
|
1206
|
+
duplication: finalKeys ? rawKeys / finalKeys : 0,
|
|
1207
|
+
readers: reader.readerCount,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1180
1211
|
public async getReaderInfo() {
|
|
1181
1212
|
let reader = await this.reader();
|
|
1182
1213
|
return {
|
|
@@ -1212,6 +1243,11 @@ type ResolvedReader = {
|
|
|
1212
1243
|
rowCount: number;
|
|
1213
1244
|
totalBytes: number;
|
|
1214
1245
|
keys: string[];
|
|
1246
|
+
// Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
|
|
1247
|
+
// N files counts N times) — and how many readers there are. Already in memory after the join, so
|
|
1248
|
+
// getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
|
|
1249
|
+
rawKeyCount: number;
|
|
1250
|
+
readerCount: number;
|
|
1215
1251
|
columns: { column: string; byteSize: number }[];
|
|
1216
1252
|
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
1217
1253
|
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
|
|
@@ -1256,6 +1292,8 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
|
|
|
1256
1292
|
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
1257
1293
|
rowCount: keys.length,
|
|
1258
1294
|
keys,
|
|
1295
|
+
rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
|
|
1296
|
+
readerCount: databases.length,
|
|
1259
1297
|
columns,
|
|
1260
1298
|
async getColumn(column) {
|
|
1261
1299
|
const perReader = await Promise.all(databases.map(async db => {
|
|
@@ -50,6 +50,7 @@ export type FileWrapper = {
|
|
|
50
50
|
export type DirectoryWrapper = {
|
|
51
51
|
readonly kind: "directory";
|
|
52
52
|
readonly name: string;
|
|
53
|
+
readonly fullPath?: string;
|
|
53
54
|
removeEntry(key: string, options?: {
|
|
54
55
|
recursive?: boolean;
|
|
55
56
|
}): Promise<void>;
|
|
@@ -89,6 +90,7 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
89
90
|
constructor(rootPath: string);
|
|
90
91
|
readonly kind: "directory";
|
|
91
92
|
get name(): string;
|
|
93
|
+
get fullPath(): string;
|
|
92
94
|
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
93
95
|
removeEntry(key: string, options?: {
|
|
94
96
|
recursive?: boolean;
|
|
@@ -60,6 +60,9 @@ export type FileWrapper = {
|
|
|
60
60
|
export type DirectoryWrapper = {
|
|
61
61
|
readonly kind: "directory";
|
|
62
62
|
readonly name: string;
|
|
63
|
+
// Full path from the storage root, for diagnostics/logging (the native handle doesn't expose paths,
|
|
64
|
+
// so it's optional). e.g. "bulkDatabases2/myCollection".
|
|
65
|
+
readonly fullPath?: string;
|
|
63
66
|
removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
|
|
64
67
|
getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
|
|
65
68
|
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
@@ -291,6 +294,7 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
291
294
|
}
|
|
292
295
|
readonly kind = "directory" as const;
|
|
293
296
|
get name() { return path.basename(this.rootPath); }
|
|
297
|
+
get fullPath() { return this.rootPath; }
|
|
294
298
|
entries() { return this[Symbol.asyncIterator](); }
|
|
295
299
|
|
|
296
300
|
async removeEntry(key: string, options?: { recursive?: boolean }) {
|
|
@@ -574,9 +578,11 @@ async function readWithRetry<T>(label: string, key: string, read: () => Promise<
|
|
|
574
578
|
}
|
|
575
579
|
|
|
576
580
|
function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
|
|
581
|
+
// Log the full path (root + filename) when available, so a failing file is identifiable.
|
|
582
|
+
const pathOf = (key: string) => handle.fullPath ? handle.fullPath + "/" + key : key;
|
|
577
583
|
return {
|
|
578
584
|
async getInfo(key: string) {
|
|
579
|
-
return readWithRetry("getInfo", key, async () => {
|
|
585
|
+
return readWithRetry("getInfo", pathOf(key), async () => {
|
|
580
586
|
const file = await handle.getFileHandle(key);
|
|
581
587
|
const fileContent = await file.getFile();
|
|
582
588
|
return {
|
|
@@ -586,7 +592,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
|
|
|
586
592
|
});
|
|
587
593
|
},
|
|
588
594
|
async get(key: string): Promise<Buffer | undefined> {
|
|
589
|
-
return readWithRetry("get", key, async () => {
|
|
595
|
+
return readWithRetry("get", pathOf(key), async () => {
|
|
590
596
|
const file = await handle.getFileHandle(key);
|
|
591
597
|
const fileContent = await file.getFile();
|
|
592
598
|
const arrayBuffer = await fileContent.arrayBuffer();
|
|
@@ -600,7 +606,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
|
|
|
600
606
|
},
|
|
601
607
|
|
|
602
608
|
async getRange(key: string, config: { start: number; end: number }): Promise<Buffer | undefined> {
|
|
603
|
-
return readWithRetry("getRange", key, async () => {
|
|
609
|
+
return readWithRetry("getRange", pathOf(key), async () => {
|
|
604
610
|
const file = await handle.getFileHandle(key);
|
|
605
611
|
const fileContent = await file.getFile();
|
|
606
612
|
const clampedStart = Math.min(Math.max(config.start, 0), fileContent.size);
|
|
@@ -7,6 +7,7 @@ import os from "os";
|
|
|
7
7
|
import { execFileSync } from "child_process";
|
|
8
8
|
import { getExternalIP } from "socket-function/src/networking";
|
|
9
9
|
import { forwardPort } from "socket-function/src/forwardPort";
|
|
10
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
10
11
|
|
|
11
12
|
// Remote file server for sliftutils getRemoteFileStorage / BulkDatabase2. Serves one folder on disk over
|
|
12
13
|
// self-signed HTTPS, authenticated with an auto-generated 6-word password (sent as
|
|
@@ -400,6 +401,85 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
400
401
|
}
|
|
401
402
|
});
|
|
402
403
|
|
|
404
|
+
// WebSocket transport: same operations as the HTTP handler, but binary-framed and multiplexed over one
|
|
405
|
+
// socket so large concurrent reads don't pay per-request HTTP overhead. Frame: [u32 headerLen LE][header
|
|
406
|
+
// JSON][body bytes]. Request header {id, op, path, start?, end?, password?}; response {id, status, error?}.
|
|
407
|
+
const EMPTY = Buffer.alloc(0);
|
|
408
|
+
const wss = new WebSocketServer({ server });
|
|
409
|
+
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
410
|
+
const ip = clientIp(req);
|
|
411
|
+
let authed = false;
|
|
412
|
+
ws.on("message", (data: Buffer) => {
|
|
413
|
+
let id = 0;
|
|
414
|
+
const reply = (status: number, extra: object, body?: Buffer) => {
|
|
415
|
+
const h = Buffer.from(JSON.stringify(Object.assign({ id, status }, extra)), "utf8");
|
|
416
|
+
const len = Buffer.alloc(4);
|
|
417
|
+
len.writeUInt32LE(h.length, 0);
|
|
418
|
+
ws.send(Buffer.concat([len, h, body || EMPTY]));
|
|
419
|
+
};
|
|
420
|
+
void (async () => {
|
|
421
|
+
let header: any;
|
|
422
|
+
let body: Buffer;
|
|
423
|
+
try {
|
|
424
|
+
const headerLen = data.readUInt32LE(0);
|
|
425
|
+
header = JSON.parse(data.subarray(4, 4 + headerLen).toString("utf8"));
|
|
426
|
+
body = data.subarray(4 + headerLen);
|
|
427
|
+
} catch { return; }
|
|
428
|
+
id = header.id || 0;
|
|
429
|
+
try {
|
|
430
|
+
if (header.op === "auth") {
|
|
431
|
+
authed = timingSafeEqualStr(normalizePassword(header.password || ""), normPassword);
|
|
432
|
+
return reply(authed ? 200 : 401, {});
|
|
433
|
+
}
|
|
434
|
+
if (!authed) return reply(401, { error: "unauthorized" });
|
|
435
|
+
const rel = String(header.path || "");
|
|
436
|
+
const full = safeResolve(root, rel);
|
|
437
|
+
if (full === undefined) return reply(403, { error: "path escapes root" });
|
|
438
|
+
if (header.op === "info") {
|
|
439
|
+
let st: fs.Stats;
|
|
440
|
+
try { st = fs.statSync(full); } catch { return reply(404, {}); }
|
|
441
|
+
return reply(200, {}, Buffer.from(JSON.stringify({ size: st.size, lastModified: st.mtimeMs, dir: st.isDirectory() })));
|
|
442
|
+
}
|
|
443
|
+
if (header.op === "list") {
|
|
444
|
+
let entries: fs.Dirent[];
|
|
445
|
+
try { entries = fs.readdirSync(full, { withFileTypes: true }); }
|
|
446
|
+
catch (e) { return (e as NodeJS.ErrnoException).code === "ENOENT" ? reply(200, {}, Buffer.from("[]")) : reply(500, { error: (e as Error).message }); }
|
|
447
|
+
return reply(200, {}, Buffer.from(JSON.stringify(entries.filter(d => d.isFile() || d.isDirectory()).map(d => ({ name: d.name, dir: d.isDirectory() })))));
|
|
448
|
+
}
|
|
449
|
+
if (header.op === "read") {
|
|
450
|
+
let st: fs.Stats;
|
|
451
|
+
try { st = fs.statSync(full); } catch { return reply(404, {}); }
|
|
452
|
+
const start = Math.min(Math.max(Number(header.start) || 0, 0), st.size);
|
|
453
|
+
const end = header.end != null ? Math.min(Math.max(Number(header.end), start), st.size) : st.size;
|
|
454
|
+
recordAccess(ip, "read", rel, end - start);
|
|
455
|
+
if (end === start) return reply(200, {}, EMPTY);
|
|
456
|
+
const fh = await fs.promises.open(full, "r");
|
|
457
|
+
try {
|
|
458
|
+
const buf = Buffer.allocUnsafe(end - start);
|
|
459
|
+
const { bytesRead } = await fh.read(buf, 0, end - start, start);
|
|
460
|
+
reply(200, {}, bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
|
|
461
|
+
} finally { await fh.close(); }
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (header.op === "append" || header.op === "set") {
|
|
465
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
466
|
+
if (header.op === "append") fs.appendFileSync(full, body);
|
|
467
|
+
else fs.writeFileSync(full, body);
|
|
468
|
+
recordAccess(ip, "write", rel, body.length);
|
|
469
|
+
return reply(200, {});
|
|
470
|
+
}
|
|
471
|
+
if (header.op === "remove") {
|
|
472
|
+
try { fs.rmSync(full, { recursive: true, force: true }); } catch (e) { return reply(500, { error: (e as Error).message }); }
|
|
473
|
+
return reply(200, {});
|
|
474
|
+
}
|
|
475
|
+
return reply(404, { error: "unknown op" });
|
|
476
|
+
} catch (e) {
|
|
477
|
+
try { reply(500, { error: String((e as Error)?.message || e) }); } catch { /* socket gone */ }
|
|
478
|
+
}
|
|
479
|
+
})();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
403
483
|
return new Promise<RemoteFileServerHandle>((resolve, reject) => {
|
|
404
484
|
server.on("error", reject);
|
|
405
485
|
server.listen(port, host, () => {
|
|
@@ -409,7 +489,7 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
409
489
|
port: actualPort,
|
|
410
490
|
password,
|
|
411
491
|
url: `https://localhost:${actualPort}`,
|
|
412
|
-
close: () => new Promise<void>(r => { if (flushTimer) clearInterval(flushTimer); server.close(() => r()); }),
|
|
492
|
+
close: () => new Promise<void>(r => { if (flushTimer) clearInterval(flushTimer); for (const c of wss.clients) c.terminate(); wss.close(); server.close(() => r()); }),
|
|
413
493
|
});
|
|
414
494
|
});
|
|
415
495
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { isNode } from "typesafecss";
|
|
2
|
-
import https from "https";
|
|
3
2
|
import type { DirectoryWrapper, FileWrapper } from "./FileFolderAPI";
|
|
4
3
|
|
|
5
4
|
// A remote server (remoteFileServer.js) exposed as a DirectoryWrapper — the SAME interface as the
|
|
@@ -17,6 +16,7 @@ const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024;
|
|
|
17
16
|
export type RemoteOptions = {
|
|
18
17
|
chunkBytes?: number;
|
|
19
18
|
cacheBytes?: number;
|
|
19
|
+
maxFetchBytes?: number; // cap per read request; large reads split into this many bytes each
|
|
20
20
|
latencyMs?: number; // artificial per-request delay, for simulating network latency in tests
|
|
21
21
|
stats?: { requestCount: number; bytesFetched: number };
|
|
22
22
|
};
|
|
@@ -35,46 +35,185 @@ type Stat = { size: number; lastModified: number; dir: boolean };
|
|
|
35
35
|
// file, not one per block. Short enough that an append by another writer shows up promptly; our own
|
|
36
36
|
// writes invalidate it immediately.
|
|
37
37
|
const INFO_TTL_MS = 2000;
|
|
38
|
+
// We keep at most this many bytes of read/write requests in flight at once (the rest queue), so a big
|
|
39
|
+
// read doesn't fire hundreds of MB of requests at the server simultaneously.
|
|
40
|
+
const MAX_INFLIGHT_BYTES = 64 * 1024 * 1024;
|
|
41
|
+
// A single read request fetches at most this much; large reads are split into this many concurrent
|
|
42
|
+
// requests (bounded by MAX_INFLIGHT_BYTES).
|
|
43
|
+
const DEFAULT_MAX_FETCH_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
const STATS_LOG_INTERVAL_MS = 10 * 1000;
|
|
45
|
+
const RECV_WINDOW_MS = 60 * 1000;
|
|
38
46
|
|
|
47
|
+
function fmtBytes(n: number): string {
|
|
48
|
+
if (n < 1024) return n + "B";
|
|
49
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
|
|
50
|
+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + "MB";
|
|
51
|
+
return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Minimal WebSocket surface common to the browser's WebSocket and the `ws` package.
|
|
55
|
+
type WSLike = {
|
|
56
|
+
readyState: number;
|
|
57
|
+
binaryType: string;
|
|
58
|
+
send(data: Uint8Array): void;
|
|
59
|
+
close(): void;
|
|
60
|
+
onopen: (() => void) | null;
|
|
61
|
+
onmessage: ((ev: { data: ArrayBuffer | Buffer }) => void) | null;
|
|
62
|
+
onclose: (() => void) | null;
|
|
63
|
+
onerror: ((e: unknown) => void) | null;
|
|
64
|
+
};
|
|
65
|
+
function makeWebSocket(url: string): WSLike {
|
|
66
|
+
if (isNode()) {
|
|
67
|
+
// `ws` is a Node dep; the eval hides it from the browser bundler (this branch never runs there).
|
|
68
|
+
const WS = (eval("require") as NodeRequire)("ws");
|
|
69
|
+
return new WS(url, { rejectUnauthorized: false }) as WSLike; // self-signed cert
|
|
70
|
+
}
|
|
71
|
+
return new WebSocket(url) as unknown as WSLike;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Binary frame: [u32 headerLen LE][header JSON][body bytes].
|
|
75
|
+
function encodeFrame(header: object, body?: Buffer): Buffer {
|
|
76
|
+
const h = Buffer.from(JSON.stringify(header), "utf8");
|
|
77
|
+
const len = Buffer.alloc(4);
|
|
78
|
+
len.writeUInt32LE(h.length, 0);
|
|
79
|
+
return Buffer.concat([len, h, body || EMPTY]);
|
|
80
|
+
}
|
|
81
|
+
function decodeFrame(buf: Buffer): { header: any; body: Buffer } {
|
|
82
|
+
const headerLen = buf.readUInt32LE(0);
|
|
83
|
+
const header = JSON.parse(buf.subarray(4, 4 + headerLen).toString("utf8"));
|
|
84
|
+
return { header, body: buf.subarray(4 + headerLen) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type QueuedRequest = { id: number; frame: Buffer; bytes: number; resolve: (r: { status: number; body: Buffer }) => void; reject: (e: Error) => void };
|
|
88
|
+
|
|
89
|
+
// One WebSocket to a remote server, multiplexing all ops over it. Requests are queued and only sent
|
|
90
|
+
// while < MAX_INFLIGHT_BYTES are outstanding; every 10s (when there's traffic) it logs the queue depth
|
|
91
|
+
// and throughput.
|
|
39
92
|
class Connection {
|
|
40
|
-
private agent = isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
|
41
93
|
private cache: RangeCache;
|
|
42
94
|
private infoCache = new Map<string, { stat: Stat | undefined; at: number }>();
|
|
95
|
+
|
|
96
|
+
private ws: WSLike | undefined;
|
|
97
|
+
private connecting: Promise<void> | undefined;
|
|
98
|
+
private nextId = 1;
|
|
99
|
+
private pending = new Map<number, { resolve: (r: { status: number; body: Buffer }) => void; reject: (e: Error) => void; bytes: number }>();
|
|
100
|
+
private queue: QueuedRequest[] = [];
|
|
101
|
+
private inFlightBytes = 0;
|
|
102
|
+
|
|
103
|
+
private receivedTotal = 0;
|
|
104
|
+
private recvWindow: { t: number; n: number }[] = [];
|
|
105
|
+
private wsUrl: string;
|
|
106
|
+
private host: string;
|
|
107
|
+
private statsTimer: ReturnType<typeof setInterval>;
|
|
108
|
+
|
|
43
109
|
constructor(public url: string, public password: string, private opts: RemoteOptions) {
|
|
44
|
-
this.cache = new RangeCache(opts.chunkBytes || DEFAULT_CHUNK_BYTES, opts.cacheBytes || DEFAULT_CACHE_BYTES);
|
|
45
110
|
this.url = url.replace(/\/+$/, "");
|
|
111
|
+
this.wsUrl = this.url.replace(/^http/, "ws"); // https→wss, http→ws
|
|
112
|
+
this.host = (() => { try { return new URL(this.url).host; } catch { return this.url; } })();
|
|
113
|
+
this.cache = new RangeCache(opts.chunkBytes || DEFAULT_CHUNK_BYTES, opts.cacheBytes || DEFAULT_CACHE_BYTES, opts.maxFetchBytes || DEFAULT_MAX_FETCH_BYTES);
|
|
114
|
+
this.statsTimer = setInterval(() => this.logStats(), STATS_LOG_INTERVAL_MS);
|
|
115
|
+
(this.statsTimer as { unref?: () => void }).unref?.();
|
|
46
116
|
}
|
|
47
117
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
close() {
|
|
119
|
+
clearInterval(this.statsTimer);
|
|
120
|
+
try { this.ws?.close(); } catch { /* */ }
|
|
121
|
+
this.ws = undefined;
|
|
122
|
+
this.connecting = undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private logStats() {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
this.recvWindow = this.recvWindow.filter(e => now - e.t < RECV_WINDOW_MS);
|
|
128
|
+
if (this.inFlightBytes === 0 && this.queue.length === 0) return; // only when there's traffic
|
|
129
|
+
const recv60 = this.recvWindow.reduce((a, e) => a + e.n, 0);
|
|
130
|
+
const queuedBytes = this.queue.reduce((a, q) => a + q.bytes, 0);
|
|
131
|
+
console.log(`[remote ${this.host}] outstanding ${fmtBytes(this.inFlightBytes)} (${this.pending.size} req), queued ${fmtBytes(queuedBytes)} (${this.queue.length} req), recv/60s ${fmtBytes(recv60)}, recv total ${fmtBytes(this.receivedTotal)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private ensureConnected(): Promise<void> {
|
|
135
|
+
if (this.ws && this.ws.readyState === 1) return Promise.resolve();
|
|
136
|
+
if (this.connecting) return this.connecting;
|
|
137
|
+
this.connecting = new Promise<void>((resolve, reject) => {
|
|
138
|
+
let authed = false;
|
|
139
|
+
const ws = makeWebSocket(this.wsUrl);
|
|
140
|
+
ws.binaryType = "arraybuffer";
|
|
141
|
+
this.ws = ws;
|
|
142
|
+
ws.onopen = () => ws.send(encodeFrame({ id: 0, op: "auth", password: this.password }));
|
|
143
|
+
ws.onmessage = (ev) => {
|
|
144
|
+
const buf = Buffer.isBuffer(ev.data) ? ev.data as Buffer : Buffer.from(ev.data as ArrayBuffer);
|
|
145
|
+
const { header } = decodeFrame(buf);
|
|
146
|
+
if (!authed && header.id === 0) {
|
|
147
|
+
if (header.status === 200) { authed = true; resolve(); }
|
|
148
|
+
else { reject(new Error("remote: authentication failed")); try { ws.close(); } catch { /* */ } }
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.onMessage(buf);
|
|
152
|
+
};
|
|
153
|
+
ws.onerror = () => { if (!authed) reject(new Error("remote: websocket connection error")); };
|
|
154
|
+
ws.onclose = () => {
|
|
155
|
+
if (!authed) reject(new Error("remote: connection closed before authenticating"));
|
|
156
|
+
this.handleDisconnect();
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
// Let a failed connection be retried next time.
|
|
160
|
+
this.connecting.catch(() => { this.ws = undefined; this.connecting = undefined; });
|
|
161
|
+
return this.connecting;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private onMessage(buf: Buffer) {
|
|
165
|
+
const { header, body } = decodeFrame(buf);
|
|
166
|
+
const p = this.pending.get(header.id);
|
|
167
|
+
if (!p) return;
|
|
168
|
+
this.pending.delete(header.id);
|
|
169
|
+
this.inFlightBytes -= p.bytes;
|
|
170
|
+
this.receivedTotal += body.length;
|
|
171
|
+
if (this.opts.stats) this.opts.stats.bytesFetched += body.length;
|
|
172
|
+
this.recvWindow.push({ t: Date.now(), n: body.length });
|
|
173
|
+
p.resolve({ status: header.status, body });
|
|
174
|
+
this.drain();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private handleDisconnect() {
|
|
178
|
+
const err = new Error("remote: websocket disconnected");
|
|
179
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
180
|
+
for (const q of this.queue) q.reject(err);
|
|
181
|
+
this.pending.clear();
|
|
182
|
+
this.queue = [];
|
|
183
|
+
this.inFlightBytes = 0;
|
|
184
|
+
this.ws = undefined;
|
|
185
|
+
this.connecting = undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private drain() {
|
|
189
|
+
while (this.queue.length && this.ws && this.ws.readyState === 1) {
|
|
190
|
+
const next = this.queue[0];
|
|
191
|
+
if (this.inFlightBytes > 0 && this.inFlightBytes + next.bytes > MAX_INFLIGHT_BYTES) break; // hold the queue
|
|
192
|
+
this.queue.shift();
|
|
193
|
+
this.inFlightBytes += next.bytes;
|
|
194
|
+
this.pending.set(next.id, { resolve: next.resolve, reject: next.reject, bytes: next.bytes });
|
|
195
|
+
if (this.opts.stats) this.opts.stats.requestCount++;
|
|
196
|
+
this.ws.send(next.frame);
|
|
69
197
|
}
|
|
70
|
-
|
|
71
|
-
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Sends one request over the WebSocket (queued + throttled). `bytes` is the expected payload size,
|
|
201
|
+
// used for the in-flight cap.
|
|
202
|
+
private async request(op: string, params: Record<string, unknown>, body: Buffer | undefined, bytes: number): Promise<{ status: number; body: Buffer }> {
|
|
203
|
+
if (this.opts.latencyMs) await sleep(this.opts.latencyMs);
|
|
204
|
+
await this.ensureConnected();
|
|
205
|
+
const id = this.nextId++;
|
|
206
|
+
const frame = encodeFrame({ id, op, ...params }, body);
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
this.queue.push({ id, frame, bytes: Math.max(bytes, body ? body.length : 0, 1), resolve, reject });
|
|
209
|
+
this.drain();
|
|
210
|
+
});
|
|
72
211
|
}
|
|
73
212
|
|
|
74
213
|
async stat(path: string): Promise<Stat | undefined> {
|
|
75
214
|
const cached = this.infoCache.get(path);
|
|
76
215
|
if (cached && Date.now() - cached.at < INFO_TTL_MS) return cached.stat;
|
|
77
|
-
const r = await this.request("
|
|
216
|
+
const r = await this.request("info", { path }, undefined, 256);
|
|
78
217
|
let stat: Stat | undefined;
|
|
79
218
|
if (r.status === 404) stat = undefined;
|
|
80
219
|
else if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
|
|
@@ -83,7 +222,7 @@ class Connection {
|
|
|
83
222
|
return stat;
|
|
84
223
|
}
|
|
85
224
|
async list(path: string): Promise<{ name: string; dir: boolean }[]> {
|
|
86
|
-
const r = await this.request("
|
|
225
|
+
const r = await this.request("list", { path }, undefined, 4096);
|
|
87
226
|
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
88
227
|
return JSON.parse(r.body.toString("utf8"));
|
|
89
228
|
}
|
|
@@ -91,26 +230,24 @@ class Connection {
|
|
|
91
230
|
return (await this.cache.read(this, path, start, end)) ?? EMPTY;
|
|
92
231
|
}
|
|
93
232
|
async readServer(path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
94
|
-
const r = await this.request("
|
|
233
|
+
const r = await this.request("read", { path, start, end }, undefined, end - start);
|
|
95
234
|
if (r.status === 404) return undefined;
|
|
96
235
|
if (r.status !== 200) throw new Error(`remote read failed (${r.status})`);
|
|
97
|
-
if (this.opts.stats) this.opts.stats.bytesFetched += r.body.length;
|
|
98
236
|
return r.body;
|
|
99
237
|
}
|
|
100
238
|
async append(path: string, body: Buffer): Promise<void> {
|
|
101
|
-
const r = await this.request("
|
|
239
|
+
const r = await this.request("append", { path }, body, body.length);
|
|
102
240
|
if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
|
|
103
|
-
//
|
|
104
|
-
this.infoCache.delete(path);
|
|
241
|
+
this.infoCache.delete(path); // append-only keeps existing bytes; only the size changed
|
|
105
242
|
}
|
|
106
243
|
async set(path: string, body: Buffer): Promise<void> {
|
|
107
|
-
const r = await this.request("
|
|
244
|
+
const r = await this.request("set", { path }, body, body.length);
|
|
108
245
|
if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
|
|
109
246
|
this.cache.invalidate(path);
|
|
110
247
|
this.infoCache.delete(path);
|
|
111
248
|
}
|
|
112
249
|
async remove(path: string): Promise<void> {
|
|
113
|
-
const r = await this.request("
|
|
250
|
+
const r = await this.request("remove", { path }, undefined, 256);
|
|
114
251
|
if (r.status !== 200) throw new Error(`remote remove failed (${r.status})`);
|
|
115
252
|
this.cache.invalidate(path);
|
|
116
253
|
this.infoCache.delete(path);
|
|
@@ -123,7 +260,7 @@ class Connection {
|
|
|
123
260
|
class RangeCache {
|
|
124
261
|
private chunks = new Map<string, Buffer>(); // path + "" + chunkIndex, insertion-ordered (LRU)
|
|
125
262
|
private bytes = 0;
|
|
126
|
-
constructor(private chunkBytes: number, private budget: number) { }
|
|
263
|
+
constructor(private chunkBytes: number, private budget: number, private maxFetchBytes: number) { }
|
|
127
264
|
private key(path: string, c: number) { return path + "" + c; }
|
|
128
265
|
private peek(path: string, c: number): Buffer | undefined {
|
|
129
266
|
const k = this.key(path, c);
|
|
@@ -152,24 +289,37 @@ class RangeCache {
|
|
|
152
289
|
async read(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
153
290
|
if (end <= start) return EMPTY;
|
|
154
291
|
const CHUNK = this.chunkBytes;
|
|
292
|
+
const maxRunChunks = Math.max(1, Math.floor(this.maxFetchBytes / CHUNK));
|
|
155
293
|
const firstChunk = Math.floor(start / CHUNK);
|
|
156
294
|
const lastChunk = Math.floor((end - 1) / CHUNK);
|
|
157
|
-
|
|
295
|
+
// Collect the chunks we don't have deeply enough into bounded contiguous runs (each <= maxFetchBytes).
|
|
296
|
+
const runs: { from: number; to: number }[] = [];
|
|
297
|
+
let runFrom = -1;
|
|
158
298
|
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
159
299
|
const cStart = c * CHUNK;
|
|
160
300
|
const needEnd = Math.min(end, cStart + CHUNK) - cStart;
|
|
161
301
|
const have = this.peek(path, c);
|
|
162
|
-
if (!have || have.length < needEnd) {
|
|
302
|
+
if (!have || have.length < needEnd) {
|
|
303
|
+
if (runFrom < 0) runFrom = c;
|
|
304
|
+
if (c - runFrom + 1 >= maxRunChunks) { runs.push({ from: runFrom, to: c }); runFrom = -1; }
|
|
305
|
+
} else if (runFrom >= 0) {
|
|
306
|
+
runs.push({ from: runFrom, to: c - 1 });
|
|
307
|
+
runFrom = -1;
|
|
308
|
+
}
|
|
163
309
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
310
|
+
if (runFrom >= 0) runs.push({ from: runFrom, to: lastChunk });
|
|
311
|
+
// Fetch all runs concurrently; the connection's queue caps total in-flight bytes.
|
|
312
|
+
let missingFile = false;
|
|
313
|
+
await Promise.all(runs.map(async run => {
|
|
314
|
+
const bytes = await conn.readServer(path, run.from * CHUNK, (run.to + 1) * CHUNK);
|
|
315
|
+
if (bytes === undefined) { missingFile = true; return; }
|
|
316
|
+
for (let c = run.from; c <= run.to; c++) {
|
|
317
|
+
const off = (c - run.from) * CHUNK;
|
|
169
318
|
if (off >= bytes.length) break;
|
|
170
319
|
this.store(path, c, bytes.subarray(off, Math.min(off + CHUNK, bytes.length)));
|
|
171
320
|
}
|
|
172
|
-
}
|
|
321
|
+
}));
|
|
322
|
+
if (missingFile) return undefined;
|
|
173
323
|
const parts: Buffer[] = [];
|
|
174
324
|
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
175
325
|
const cStart = c * CHUNK;
|
|
@@ -228,6 +378,7 @@ class RemoteDirectoryWrapper implements DirectoryWrapper {
|
|
|
228
378
|
// native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
|
|
229
379
|
readonly kind = "directory" as const;
|
|
230
380
|
get name() { return baseName(this.dirPath); }
|
|
381
|
+
get fullPath() { return this.dirPath; }
|
|
231
382
|
async removeEntry(key: string): Promise<void> {
|
|
232
383
|
await this.conn.remove(joinPath(this.dirPath, key));
|
|
233
384
|
}
|
|
@@ -271,17 +422,19 @@ export function getRemoteDirectoryHandle(url: string, password: string, options:
|
|
|
271
422
|
|
|
272
423
|
export type RemoteConnectResult = { status: "ok" } | { status: "unauthorized" } | { status: "unreachable"; error: string };
|
|
273
424
|
|
|
274
|
-
// Verifies a server is reachable and the password works — by
|
|
275
|
-
// "connected" / "wrong password" / "couldn't reach it" (the last usually
|
|
276
|
-
// isn't trusted yet in the browser, since
|
|
425
|
+
// Verifies a server is reachable and the password works — by opening the WebSocket, authenticating, and
|
|
426
|
+
// listing the root. Distinguishes "connected" / "wrong password" / "couldn't reach it" (the last usually
|
|
427
|
+
// meaning the self-signed cert isn't trusted yet in the browser, since the socket just fails to open).
|
|
277
428
|
export async function testRemoteConnection(url: string, password: string, options: RemoteOptions = {}): Promise<RemoteConnectResult> {
|
|
278
429
|
const conn = new Connection(url, password, options);
|
|
279
430
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (r.status === 401) return { status: "unauthorized" };
|
|
283
|
-
return { status: "unreachable", error: `server returned ${r.status}` };
|
|
431
|
+
await conn.list("");
|
|
432
|
+
return { status: "ok" };
|
|
284
433
|
} catch (e) {
|
|
285
|
-
|
|
434
|
+
const msg = (e as Error)?.message || String(e);
|
|
435
|
+
if (/auth/i.test(msg)) return { status: "unauthorized" };
|
|
436
|
+
return { status: "unreachable", error: msg };
|
|
437
|
+
} finally {
|
|
438
|
+
conn.close();
|
|
286
439
|
}
|
|
287
440
|
}
|