sliftutils 1.4.11 → 1.4.13
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 +15 -28
- package/package.json +1 -1
- package/storage/FileFolderAPI.d.ts +14 -28
- package/storage/FileFolderAPI.tsx +29 -34
- package/storage/remoteFileServer.ts +81 -1
- package/storage/remoteFileStorage.d.ts +1 -0
- package/storage/remoteFileStorage.ts +226 -60
package/index.d.ts
CHANGED
|
@@ -1343,6 +1343,8 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1343
1343
|
}
|
|
1344
1344
|
}
|
|
1345
1345
|
export type FileWrapper = {
|
|
1346
|
+
readonly kind: "file";
|
|
1347
|
+
readonly name: string;
|
|
1346
1348
|
getFile(): Promise<{
|
|
1347
1349
|
size: number;
|
|
1348
1350
|
lastModified: number;
|
|
@@ -1360,6 +1362,9 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1360
1362
|
}>;
|
|
1361
1363
|
};
|
|
1362
1364
|
export type DirectoryWrapper = {
|
|
1365
|
+
readonly kind: "directory";
|
|
1366
|
+
readonly name: string;
|
|
1367
|
+
readonly fullPath?: string;
|
|
1363
1368
|
removeEntry(key: string, options?: {
|
|
1364
1369
|
recursive?: boolean;
|
|
1365
1370
|
}): Promise<void>;
|
|
@@ -1369,25 +1374,15 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1369
1374
|
getDirectoryHandle(key: string, options?: {
|
|
1370
1375
|
create?: boolean;
|
|
1371
1376
|
}): Promise<DirectoryWrapper>;
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
{
|
|
1375
|
-
kind: "file";
|
|
1376
|
-
name: string;
|
|
1377
|
-
getFile(): Promise<FileWrapper>;
|
|
1378
|
-
} | {
|
|
1379
|
-
kind: "directory";
|
|
1380
|
-
name: string;
|
|
1381
|
-
getDirectoryHandle(key: string, options?: {
|
|
1382
|
-
create?: boolean;
|
|
1383
|
-
}): Promise<DirectoryWrapper>;
|
|
1384
|
-
}
|
|
1385
|
-
]>;
|
|
1377
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1378
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1386
1379
|
};
|
|
1387
1380
|
export declare function setFileAPIKey(key: string): void;
|
|
1388
1381
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
1389
1382
|
private filePath;
|
|
1390
1383
|
constructor(filePath: string);
|
|
1384
|
+
readonly kind: "file";
|
|
1385
|
+
get name(): string;
|
|
1391
1386
|
getFile(): Promise<{
|
|
1392
1387
|
size: number;
|
|
1393
1388
|
lastModified: number;
|
|
@@ -1407,6 +1402,10 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1407
1402
|
export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
1408
1403
|
private rootPath;
|
|
1409
1404
|
constructor(rootPath: string);
|
|
1405
|
+
readonly kind: "directory";
|
|
1406
|
+
get name(): string;
|
|
1407
|
+
get fullPath(): string;
|
|
1408
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1410
1409
|
removeEntry(key: string, options?: {
|
|
1411
1410
|
recursive?: boolean;
|
|
1412
1411
|
}): Promise<void>;
|
|
@@ -1416,20 +1415,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1416
1415
|
getDirectoryHandle(key: string, options?: {
|
|
1417
1416
|
create?: boolean;
|
|
1418
1417
|
}): Promise<DirectoryWrapper>;
|
|
1419
|
-
[Symbol.asyncIterator](): AsyncIterableIterator<[
|
|
1420
|
-
string,
|
|
1421
|
-
{
|
|
1422
|
-
kind: "file";
|
|
1423
|
-
name: string;
|
|
1424
|
-
getFile(): Promise<FileWrapper>;
|
|
1425
|
-
} | {
|
|
1426
|
-
kind: "directory";
|
|
1427
|
-
name: string;
|
|
1428
|
-
getDirectoryHandle(key: string, options?: {
|
|
1429
|
-
create?: boolean;
|
|
1430
|
-
}): Promise<DirectoryWrapper>;
|
|
1431
|
-
}
|
|
1432
|
-
]>;
|
|
1418
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1433
1419
|
}
|
|
1434
1420
|
export declare const getDirectoryHandle: {
|
|
1435
1421
|
(): Promise<DirectoryWrapper>;
|
|
@@ -1866,6 +1852,7 @@ declare module "sliftutils/storage/remoteFileStorage" {
|
|
|
1866
1852
|
export type RemoteOptions = {
|
|
1867
1853
|
chunkBytes?: number;
|
|
1868
1854
|
cacheBytes?: number;
|
|
1855
|
+
maxFetchBytes?: number;
|
|
1869
1856
|
latencyMs?: number;
|
|
1870
1857
|
stats?: {
|
|
1871
1858
|
requestCount: number;
|
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ declare global {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
export type FileWrapper = {
|
|
32
|
+
readonly kind: "file";
|
|
33
|
+
readonly name: string;
|
|
32
34
|
getFile(): Promise<{
|
|
33
35
|
size: number;
|
|
34
36
|
lastModified: number;
|
|
@@ -46,6 +48,9 @@ export type FileWrapper = {
|
|
|
46
48
|
}>;
|
|
47
49
|
};
|
|
48
50
|
export type DirectoryWrapper = {
|
|
51
|
+
readonly kind: "directory";
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly fullPath?: string;
|
|
49
54
|
removeEntry(key: string, options?: {
|
|
50
55
|
recursive?: boolean;
|
|
51
56
|
}): Promise<void>;
|
|
@@ -55,25 +60,15 @@ export type DirectoryWrapper = {
|
|
|
55
60
|
getDirectoryHandle(key: string, options?: {
|
|
56
61
|
create?: boolean;
|
|
57
62
|
}): Promise<DirectoryWrapper>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
kind: "file";
|
|
62
|
-
name: string;
|
|
63
|
-
getFile(): Promise<FileWrapper>;
|
|
64
|
-
} | {
|
|
65
|
-
kind: "directory";
|
|
66
|
-
name: string;
|
|
67
|
-
getDirectoryHandle(key: string, options?: {
|
|
68
|
-
create?: boolean;
|
|
69
|
-
}): Promise<DirectoryWrapper>;
|
|
70
|
-
}
|
|
71
|
-
]>;
|
|
63
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
64
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
72
65
|
};
|
|
73
66
|
export declare function setFileAPIKey(key: string): void;
|
|
74
67
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
75
68
|
private filePath;
|
|
76
69
|
constructor(filePath: string);
|
|
70
|
+
readonly kind: "file";
|
|
71
|
+
get name(): string;
|
|
77
72
|
getFile(): Promise<{
|
|
78
73
|
size: number;
|
|
79
74
|
lastModified: number;
|
|
@@ -93,6 +88,10 @@ export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
|
93
88
|
export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
94
89
|
private rootPath;
|
|
95
90
|
constructor(rootPath: string);
|
|
91
|
+
readonly kind: "directory";
|
|
92
|
+
get name(): string;
|
|
93
|
+
get fullPath(): string;
|
|
94
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
96
95
|
removeEntry(key: string, options?: {
|
|
97
96
|
recursive?: boolean;
|
|
98
97
|
}): Promise<void>;
|
|
@@ -102,20 +101,7 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
102
101
|
getDirectoryHandle(key: string, options?: {
|
|
103
102
|
create?: boolean;
|
|
104
103
|
}): Promise<DirectoryWrapper>;
|
|
105
|
-
[Symbol.asyncIterator](): AsyncIterableIterator<[
|
|
106
|
-
string,
|
|
107
|
-
{
|
|
108
|
-
kind: "file";
|
|
109
|
-
name: string;
|
|
110
|
-
getFile(): Promise<FileWrapper>;
|
|
111
|
-
} | {
|
|
112
|
-
kind: "directory";
|
|
113
|
-
name: string;
|
|
114
|
-
getDirectoryHandle(key: string, options?: {
|
|
115
|
-
create?: boolean;
|
|
116
|
-
}): Promise<DirectoryWrapper>;
|
|
117
|
-
}
|
|
118
|
-
]>;
|
|
104
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
119
105
|
}
|
|
120
106
|
export declare const getDirectoryHandle: {
|
|
121
107
|
(): Promise<DirectoryWrapper>;
|
|
@@ -36,7 +36,13 @@ declare global {
|
|
|
36
36
|
// DO NOT enable this is isNode
|
|
37
37
|
const USE_INDEXED_DB = false;
|
|
38
38
|
|
|
39
|
+
// These mirror the subset of the native FileSystemFileHandle / FileSystemDirectoryHandle API we use, so
|
|
40
|
+
// the native browser handles, the Node handles, and the remote handles are all interchangeable — and
|
|
41
|
+
// code written against the native handle (e.g. a recursive walk over `handle.entries()`) works on any of
|
|
42
|
+
// them. kind/name and entries() are part of that contract.
|
|
39
43
|
export type FileWrapper = {
|
|
44
|
+
readonly kind: "file";
|
|
45
|
+
readonly name: string;
|
|
40
46
|
getFile(): Promise<{
|
|
41
47
|
size: number;
|
|
42
48
|
lastModified: number;
|
|
@@ -52,18 +58,17 @@ export type FileWrapper = {
|
|
|
52
58
|
}>;
|
|
53
59
|
};
|
|
54
60
|
export type DirectoryWrapper = {
|
|
61
|
+
readonly kind: "directory";
|
|
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;
|
|
55
66
|
removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
|
|
56
67
|
getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
|
|
57
68
|
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
getFile(): Promise<FileWrapper>;
|
|
62
|
-
} | {
|
|
63
|
-
kind: "directory";
|
|
64
|
-
name: string;
|
|
65
|
-
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
66
|
-
}]>;
|
|
69
|
+
// Each entry IS a handle (file or directory), so a recursive walk keeps working at every level.
|
|
70
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
71
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
67
72
|
};
|
|
68
73
|
|
|
69
74
|
let displayData = observable({
|
|
@@ -222,6 +227,8 @@ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteC
|
|
|
222
227
|
export class NodeJSFileHandleWrapper implements FileWrapper {
|
|
223
228
|
constructor(private filePath: string) {
|
|
224
229
|
}
|
|
230
|
+
readonly kind = "file" as const;
|
|
231
|
+
get name() { return path.basename(this.filePath); }
|
|
225
232
|
|
|
226
233
|
async getFile() {
|
|
227
234
|
const stats = await fs.promises.stat(this.filePath);
|
|
@@ -285,6 +292,10 @@ export class NodeJSFileHandleWrapper implements FileWrapper {
|
|
|
285
292
|
export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
286
293
|
constructor(private rootPath: string) {
|
|
287
294
|
}
|
|
295
|
+
readonly kind = "directory" as const;
|
|
296
|
+
get name() { return path.basename(this.rootPath); }
|
|
297
|
+
get fullPath() { return this.rootPath; }
|
|
298
|
+
entries() { return this[Symbol.asyncIterator](); }
|
|
288
299
|
|
|
289
300
|
async removeEntry(key: string, options?: { recursive?: boolean }) {
|
|
290
301
|
const entryPath = path.join(this.rootPath, key);
|
|
@@ -332,36 +343,18 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
332
343
|
return new NodeJSDirectoryHandleWrapper(dirPath);
|
|
333
344
|
}
|
|
334
345
|
|
|
335
|
-
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, {
|
|
336
|
-
kind: "file";
|
|
337
|
-
name: string;
|
|
338
|
-
getFile(): Promise<FileWrapper>;
|
|
339
|
-
} | {
|
|
340
|
-
kind: "directory";
|
|
341
|
-
name: string;
|
|
342
|
-
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
343
|
-
}]> {
|
|
346
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]> {
|
|
344
347
|
// Ensure directory exists
|
|
345
348
|
await fs.promises.mkdir(this.rootPath, { recursive: true });
|
|
346
349
|
|
|
347
350
|
const entries = await fs.promises.readdir(this.rootPath, { withFileTypes: true });
|
|
348
351
|
|
|
349
352
|
for (const entry of entries) {
|
|
353
|
+
const childPath = path.join(this.rootPath, entry.name);
|
|
350
354
|
if (entry.isFile()) {
|
|
351
|
-
yield [entry.name,
|
|
352
|
-
kind: "file",
|
|
353
|
-
name: entry.name,
|
|
354
|
-
getFile: async () => new NodeJSFileHandleWrapper(path.join(this.rootPath, entry.name))
|
|
355
|
-
}];
|
|
355
|
+
yield [entry.name, new NodeJSFileHandleWrapper(childPath)];
|
|
356
356
|
} else if (entry.isDirectory()) {
|
|
357
|
-
|
|
358
|
-
yield [entry.name, {
|
|
359
|
-
kind: "directory",
|
|
360
|
-
name: entry.name,
|
|
361
|
-
getDirectoryHandle: async (key: string, options?: { create?: boolean }) => {
|
|
362
|
-
return new NodeJSDirectoryHandleWrapper(dirPath).getDirectoryHandle(key, options);
|
|
363
|
-
}
|
|
364
|
-
}];
|
|
357
|
+
yield [entry.name, new NodeJSDirectoryHandleWrapper(childPath)];
|
|
365
358
|
}
|
|
366
359
|
}
|
|
367
360
|
}
|
|
@@ -585,9 +578,11 @@ async function readWithRetry<T>(label: string, key: string, read: () => Promise<
|
|
|
585
578
|
}
|
|
586
579
|
|
|
587
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;
|
|
588
583
|
return {
|
|
589
584
|
async getInfo(key: string) {
|
|
590
|
-
return readWithRetry("getInfo", key, async () => {
|
|
585
|
+
return readWithRetry("getInfo", pathOf(key), async () => {
|
|
591
586
|
const file = await handle.getFileHandle(key);
|
|
592
587
|
const fileContent = await file.getFile();
|
|
593
588
|
return {
|
|
@@ -597,7 +592,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
|
|
|
597
592
|
});
|
|
598
593
|
},
|
|
599
594
|
async get(key: string): Promise<Buffer | undefined> {
|
|
600
|
-
return readWithRetry("get", key, async () => {
|
|
595
|
+
return readWithRetry("get", pathOf(key), async () => {
|
|
601
596
|
const file = await handle.getFileHandle(key);
|
|
602
597
|
const fileContent = await file.getFile();
|
|
603
598
|
const arrayBuffer = await fileContent.arrayBuffer();
|
|
@@ -611,7 +606,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
|
|
|
611
606
|
},
|
|
612
607
|
|
|
613
608
|
async getRange(key: string, config: { start: number; end: number }): Promise<Buffer | undefined> {
|
|
614
|
-
return readWithRetry("getRange", key, async () => {
|
|
609
|
+
return readWithRetry("getRange", pathOf(key), async () => {
|
|
615
610
|
const file = await handle.getFileHandle(key);
|
|
616
611
|
const fileContent = await file.getFile();
|
|
617
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;
|
|
@@ -184,11 +334,15 @@ class RangeCache {
|
|
|
184
334
|
}
|
|
185
335
|
|
|
186
336
|
const joinPath = (base: string, key: string) => (base ? base + "/" + key : key);
|
|
337
|
+
const baseName = (p: string) => p.split("/").filter(Boolean).pop() || "";
|
|
187
338
|
|
|
188
339
|
class RemoteFileWrapper implements FileWrapper {
|
|
189
340
|
// `stat` is supplied when the parent already statted us (avoids a second /info). `createIntent` means
|
|
190
341
|
// this was opened with create:true, so a missing file reads as empty (it'll be created on write).
|
|
191
342
|
constructor(private conn: Connection, private filePath: string, private stat?: Stat, private createIntent = false) { }
|
|
343
|
+
// Mirror the native FileSystemFileHandle shape so code written against it works unchanged.
|
|
344
|
+
readonly kind = "file" as const;
|
|
345
|
+
get name() { return baseName(this.filePath); }
|
|
192
346
|
async getFile() {
|
|
193
347
|
let stat = this.stat ?? await this.conn.stat(this.filePath);
|
|
194
348
|
if (!stat) {
|
|
@@ -220,17 +374,22 @@ class RemoteFileWrapper implements FileWrapper {
|
|
|
220
374
|
|
|
221
375
|
class RemoteDirectoryWrapper implements DirectoryWrapper {
|
|
222
376
|
constructor(private conn: Connection, private dirPath: string) { }
|
|
377
|
+
// Mirror the native FileSystemDirectoryHandle shape (name/kind/entries) so code written against the
|
|
378
|
+
// native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
|
|
379
|
+
readonly kind = "directory" as const;
|
|
380
|
+
get name() { return baseName(this.dirPath); }
|
|
381
|
+
get fullPath() { return this.dirPath; }
|
|
223
382
|
async removeEntry(key: string): Promise<void> {
|
|
224
383
|
await this.conn.remove(joinPath(this.dirPath, key));
|
|
225
384
|
}
|
|
226
|
-
async getFileHandle(key: string, options?: { create?: boolean }): Promise<
|
|
385
|
+
async getFileHandle(key: string, options?: { create?: boolean }): Promise<RemoteFileWrapper> {
|
|
227
386
|
const p = joinPath(this.dirPath, key);
|
|
228
387
|
if (options?.create) return new RemoteFileWrapper(this.conn, p, undefined, true);
|
|
229
388
|
const stat = await this.conn.stat(p); // matches the File API: throw if missing
|
|
230
389
|
if (!stat || stat.dir) throw enoent(p);
|
|
231
390
|
return new RemoteFileWrapper(this.conn, p, stat, false);
|
|
232
391
|
}
|
|
233
|
-
async getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<
|
|
392
|
+
async getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<RemoteDirectoryWrapper> {
|
|
234
393
|
const p = joinPath(this.dirPath, key);
|
|
235
394
|
if (!options?.create) {
|
|
236
395
|
const stat = await this.conn.stat(p);
|
|
@@ -238,17 +397,22 @@ class RemoteDirectoryWrapper implements DirectoryWrapper {
|
|
|
238
397
|
}
|
|
239
398
|
return new RemoteDirectoryWrapper(this.conn, p); // dirs are created lazily on first write
|
|
240
399
|
}
|
|
241
|
-
|
|
400
|
+
// Each entry is itself a real handle (a sub-wrapper), so a recursive walk over .entries() keeps
|
|
401
|
+
// working at every level — exactly like the native API.
|
|
402
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, RemoteFileWrapper | RemoteDirectoryWrapper]> {
|
|
242
403
|
const entries = await this.conn.list(this.dirPath);
|
|
243
404
|
for (const e of entries) {
|
|
244
405
|
const childPath = joinPath(this.dirPath, e.name);
|
|
245
|
-
|
|
246
|
-
yield [e.name, { kind: "directory", name: e.name, getDirectoryHandle: (k, o) => new RemoteDirectoryWrapper(this.conn, childPath).getDirectoryHandle(k, o) }];
|
|
247
|
-
} else {
|
|
248
|
-
yield [e.name, { kind: "file", name: e.name, getFile: async () => new RemoteFileWrapper(this.conn, childPath) }];
|
|
249
|
-
}
|
|
406
|
+
yield [e.name, e.dir ? new RemoteDirectoryWrapper(this.conn, childPath) : new RemoteFileWrapper(this.conn, childPath)];
|
|
250
407
|
}
|
|
251
408
|
}
|
|
409
|
+
entries() { return this[Symbol.asyncIterator](); }
|
|
410
|
+
keys() { return mapAsync(this[Symbol.asyncIterator](), ([name]) => name); }
|
|
411
|
+
values() { return mapAsync(this[Symbol.asyncIterator](), ([, handle]) => handle); }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function* mapAsync<T, U>(it: AsyncIterableIterator<T>, fn: (v: T) => U): AsyncIterableIterator<U> {
|
|
415
|
+
for await (const v of it) yield fn(v);
|
|
252
416
|
}
|
|
253
417
|
|
|
254
418
|
// A DirectoryWrapper rooted at a remote server. Drop-in for the Node / File-API handles.
|
|
@@ -258,17 +422,19 @@ export function getRemoteDirectoryHandle(url: string, password: string, options:
|
|
|
258
422
|
|
|
259
423
|
export type RemoteConnectResult = { status: "ok" } | { status: "unauthorized" } | { status: "unreachable"; error: string };
|
|
260
424
|
|
|
261
|
-
// Verifies a server is reachable and the password works — by
|
|
262
|
-
// "connected" / "wrong password" / "couldn't reach it" (the last usually
|
|
263
|
-
// 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).
|
|
264
428
|
export async function testRemoteConnection(url: string, password: string, options: RemoteOptions = {}): Promise<RemoteConnectResult> {
|
|
265
429
|
const conn = new Connection(url, password, options);
|
|
266
430
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (r.status === 401) return { status: "unauthorized" };
|
|
270
|
-
return { status: "unreachable", error: `server returned ${r.status}` };
|
|
431
|
+
await conn.list("");
|
|
432
|
+
return { status: "ok" };
|
|
271
433
|
} catch (e) {
|
|
272
|
-
|
|
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();
|
|
273
439
|
}
|
|
274
440
|
}
|