sliftutils 1.4.10 → 1.4.12
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 +22 -80
- package/package.json +1 -1
- package/storage/FileFolderAPI.d.ts +16 -47
- package/storage/FileFolderAPI.tsx +89 -88
- package/storage/remoteFileServer.ts +21 -17
- package/storage/remoteFileStorage.d.ts +6 -33
- package/storage/remoteFileStorage.ts +201 -174
package/index.d.ts
CHANGED
|
@@ -1315,6 +1315,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1315
1315
|
/// <reference types="node" />
|
|
1316
1316
|
/// <reference types="node" />
|
|
1317
1317
|
import { IStorageRaw } from "./IStorage";
|
|
1318
|
+
import { RemoteOptions } from "./remoteFileStorage";
|
|
1318
1319
|
declare global {
|
|
1319
1320
|
interface Window {
|
|
1320
1321
|
showSaveFilePicker(config?: {
|
|
@@ -1341,7 +1342,9 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1341
1342
|
}): Promise<PermissionState>;
|
|
1342
1343
|
}
|
|
1343
1344
|
}
|
|
1344
|
-
type FileWrapper = {
|
|
1345
|
+
export type FileWrapper = {
|
|
1346
|
+
readonly kind: "file";
|
|
1347
|
+
readonly name: string;
|
|
1345
1348
|
getFile(): Promise<{
|
|
1346
1349
|
size: number;
|
|
1347
1350
|
lastModified: number;
|
|
@@ -1358,7 +1361,9 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1358
1361
|
close(): Promise<void>;
|
|
1359
1362
|
}>;
|
|
1360
1363
|
};
|
|
1361
|
-
type DirectoryWrapper = {
|
|
1364
|
+
export type DirectoryWrapper = {
|
|
1365
|
+
readonly kind: "directory";
|
|
1366
|
+
readonly name: string;
|
|
1362
1367
|
removeEntry(key: string, options?: {
|
|
1363
1368
|
recursive?: boolean;
|
|
1364
1369
|
}): Promise<void>;
|
|
@@ -1368,29 +1373,15 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1368
1373
|
getDirectoryHandle(key: string, options?: {
|
|
1369
1374
|
create?: boolean;
|
|
1370
1375
|
}): Promise<DirectoryWrapper>;
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
{
|
|
1374
|
-
kind: "file";
|
|
1375
|
-
name: string;
|
|
1376
|
-
getFile(): Promise<FileWrapper>;
|
|
1377
|
-
} | {
|
|
1378
|
-
kind: "directory";
|
|
1379
|
-
name: string;
|
|
1380
|
-
getDirectoryHandle(key: string, options?: {
|
|
1381
|
-
create?: boolean;
|
|
1382
|
-
}): Promise<DirectoryWrapper>;
|
|
1383
|
-
}
|
|
1384
|
-
]>;
|
|
1376
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1377
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1385
1378
|
};
|
|
1386
1379
|
export declare function setFileAPIKey(key: string): void;
|
|
1387
|
-
type RemoteConfig = {
|
|
1388
|
-
url: string;
|
|
1389
|
-
password: string;
|
|
1390
|
-
};
|
|
1391
1380
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
1392
1381
|
private filePath;
|
|
1393
1382
|
constructor(filePath: string);
|
|
1383
|
+
readonly kind: "file";
|
|
1384
|
+
get name(): string;
|
|
1394
1385
|
getFile(): Promise<{
|
|
1395
1386
|
size: number;
|
|
1396
1387
|
lastModified: number;
|
|
@@ -1410,6 +1401,9 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1410
1401
|
export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
1411
1402
|
private rootPath;
|
|
1412
1403
|
constructor(rootPath: string);
|
|
1404
|
+
readonly kind: "directory";
|
|
1405
|
+
get name(): string;
|
|
1406
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1413
1407
|
removeEntry(key: string, options?: {
|
|
1414
1408
|
recursive?: boolean;
|
|
1415
1409
|
}): Promise<void>;
|
|
@@ -1419,33 +1413,8 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1419
1413
|
getDirectoryHandle(key: string, options?: {
|
|
1420
1414
|
create?: boolean;
|
|
1421
1415
|
}): Promise<DirectoryWrapper>;
|
|
1422
|
-
[Symbol.asyncIterator](): AsyncIterableIterator<[
|
|
1423
|
-
string,
|
|
1424
|
-
{
|
|
1425
|
-
kind: "file";
|
|
1426
|
-
name: string;
|
|
1427
|
-
getFile(): Promise<FileWrapper>;
|
|
1428
|
-
} | {
|
|
1429
|
-
kind: "directory";
|
|
1430
|
-
name: string;
|
|
1431
|
-
getDirectoryHandle(key: string, options?: {
|
|
1432
|
-
create?: boolean;
|
|
1433
|
-
}): Promise<DirectoryWrapper>;
|
|
1434
|
-
}
|
|
1435
|
-
]>;
|
|
1416
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
1436
1417
|
}
|
|
1437
|
-
type StorageChoice = {
|
|
1438
|
-
type: "local";
|
|
1439
|
-
handle: DirectoryWrapper;
|
|
1440
|
-
} | {
|
|
1441
|
-
type: "remote";
|
|
1442
|
-
config: RemoteConfig;
|
|
1443
|
-
};
|
|
1444
|
-
export declare const getStorageChoice: {
|
|
1445
|
-
(): Promise<StorageChoice>;
|
|
1446
|
-
reset(): void;
|
|
1447
|
-
set(newValue: Promise<StorageChoice>): void;
|
|
1448
|
-
};
|
|
1449
1418
|
export declare const getDirectoryHandle: {
|
|
1450
1419
|
(): Promise<DirectoryWrapper>;
|
|
1451
1420
|
reset(): void;
|
|
@@ -1483,8 +1452,8 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1483
1452
|
folder: NestedFileStorage;
|
|
1484
1453
|
};
|
|
1485
1454
|
export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
|
|
1455
|
+
export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
|
|
1486
1456
|
export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
|
|
1487
|
-
export {};
|
|
1488
1457
|
|
|
1489
1458
|
}
|
|
1490
1459
|
|
|
@@ -1877,44 +1846,18 @@ declare module "sliftutils/storage/remoteFileServer" {
|
|
|
1877
1846
|
}
|
|
1878
1847
|
|
|
1879
1848
|
declare module "sliftutils/storage/remoteFileStorage" {
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
/// <reference types="node" />
|
|
1883
|
-
import https from "https";
|
|
1884
|
-
import type { FileStorage } from "./FileFolderAPI";
|
|
1885
|
-
export type RemoteFileStorageOptions = {
|
|
1849
|
+
import type { DirectoryWrapper } from "./FileFolderAPI";
|
|
1850
|
+
export type RemoteOptions = {
|
|
1886
1851
|
chunkBytes?: number;
|
|
1887
1852
|
cacheBytes?: number;
|
|
1888
1853
|
latencyMs?: number;
|
|
1889
|
-
|
|
1890
|
-
type Connection = {
|
|
1891
|
-
url: string;
|
|
1892
|
-
password: string;
|
|
1893
|
-
latencyMs: number;
|
|
1894
|
-
agent: https.Agent | undefined;
|
|
1895
|
-
cache: RangeCache;
|
|
1896
|
-
stats: {
|
|
1854
|
+
stats?: {
|
|
1897
1855
|
requestCount: number;
|
|
1898
1856
|
bytesFetched: number;
|
|
1899
1857
|
};
|
|
1900
1858
|
};
|
|
1901
|
-
declare
|
|
1902
|
-
|
|
1903
|
-
private budget;
|
|
1904
|
-
private chunks;
|
|
1905
|
-
private bytes;
|
|
1906
|
-
constructor(chunkBytes: number, budget: number);
|
|
1907
|
-
private key;
|
|
1908
|
-
private peek;
|
|
1909
|
-
private store;
|
|
1910
|
-
invalidate(path: string): void;
|
|
1911
|
-
getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined>;
|
|
1912
|
-
}
|
|
1913
|
-
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & {
|
|
1914
|
-
stats: Connection["stats"];
|
|
1915
|
-
};
|
|
1916
|
-
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
1917
|
-
export type RemoteProbeResult = {
|
|
1859
|
+
export declare function getRemoteDirectoryHandle(url: string, password: string, options?: RemoteOptions): DirectoryWrapper;
|
|
1860
|
+
export type RemoteConnectResult = {
|
|
1918
1861
|
status: "ok";
|
|
1919
1862
|
} | {
|
|
1920
1863
|
status: "unauthorized";
|
|
@@ -1922,8 +1865,7 @@ declare module "sliftutils/storage/remoteFileStorage" {
|
|
|
1922
1865
|
status: "unreachable";
|
|
1923
1866
|
error: string;
|
|
1924
1867
|
};
|
|
1925
|
-
export declare function
|
|
1926
|
-
export {};
|
|
1868
|
+
export declare function testRemoteConnection(url: string, password: string, options?: RemoteOptions): Promise<RemoteConnectResult>;
|
|
1927
1869
|
|
|
1928
1870
|
}
|
|
1929
1871
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
import { IStorageRaw } from "./IStorage";
|
|
4
|
+
import { RemoteOptions } from "./remoteFileStorage";
|
|
4
5
|
declare global {
|
|
5
6
|
interface Window {
|
|
6
7
|
showSaveFilePicker(config?: {
|
|
@@ -27,7 +28,9 @@ declare global {
|
|
|
27
28
|
}): Promise<PermissionState>;
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
type FileWrapper = {
|
|
31
|
+
export type FileWrapper = {
|
|
32
|
+
readonly kind: "file";
|
|
33
|
+
readonly name: string;
|
|
31
34
|
getFile(): Promise<{
|
|
32
35
|
size: number;
|
|
33
36
|
lastModified: number;
|
|
@@ -44,7 +47,9 @@ type FileWrapper = {
|
|
|
44
47
|
close(): Promise<void>;
|
|
45
48
|
}>;
|
|
46
49
|
};
|
|
47
|
-
type DirectoryWrapper = {
|
|
50
|
+
export type DirectoryWrapper = {
|
|
51
|
+
readonly kind: "directory";
|
|
52
|
+
readonly name: string;
|
|
48
53
|
removeEntry(key: string, options?: {
|
|
49
54
|
recursive?: boolean;
|
|
50
55
|
}): Promise<void>;
|
|
@@ -54,29 +59,15 @@ type DirectoryWrapper = {
|
|
|
54
59
|
getDirectoryHandle(key: string, options?: {
|
|
55
60
|
create?: boolean;
|
|
56
61
|
}): Promise<DirectoryWrapper>;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
{
|
|
60
|
-
kind: "file";
|
|
61
|
-
name: string;
|
|
62
|
-
getFile(): Promise<FileWrapper>;
|
|
63
|
-
} | {
|
|
64
|
-
kind: "directory";
|
|
65
|
-
name: string;
|
|
66
|
-
getDirectoryHandle(key: string, options?: {
|
|
67
|
-
create?: boolean;
|
|
68
|
-
}): Promise<DirectoryWrapper>;
|
|
69
|
-
}
|
|
70
|
-
]>;
|
|
62
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
63
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
71
64
|
};
|
|
72
65
|
export declare function setFileAPIKey(key: string): void;
|
|
73
|
-
type RemoteConfig = {
|
|
74
|
-
url: string;
|
|
75
|
-
password: string;
|
|
76
|
-
};
|
|
77
66
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
78
67
|
private filePath;
|
|
79
68
|
constructor(filePath: string);
|
|
69
|
+
readonly kind: "file";
|
|
70
|
+
get name(): string;
|
|
80
71
|
getFile(): Promise<{
|
|
81
72
|
size: number;
|
|
82
73
|
lastModified: number;
|
|
@@ -96,6 +87,9 @@ export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
|
96
87
|
export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
97
88
|
private rootPath;
|
|
98
89
|
constructor(rootPath: string);
|
|
90
|
+
readonly kind: "directory";
|
|
91
|
+
get name(): string;
|
|
92
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
99
93
|
removeEntry(key: string, options?: {
|
|
100
94
|
recursive?: boolean;
|
|
101
95
|
}): Promise<void>;
|
|
@@ -105,33 +99,8 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
105
99
|
getDirectoryHandle(key: string, options?: {
|
|
106
100
|
create?: boolean;
|
|
107
101
|
}): Promise<DirectoryWrapper>;
|
|
108
|
-
[Symbol.asyncIterator](): AsyncIterableIterator<[
|
|
109
|
-
string,
|
|
110
|
-
{
|
|
111
|
-
kind: "file";
|
|
112
|
-
name: string;
|
|
113
|
-
getFile(): Promise<FileWrapper>;
|
|
114
|
-
} | {
|
|
115
|
-
kind: "directory";
|
|
116
|
-
name: string;
|
|
117
|
-
getDirectoryHandle(key: string, options?: {
|
|
118
|
-
create?: boolean;
|
|
119
|
-
}): Promise<DirectoryWrapper>;
|
|
120
|
-
}
|
|
121
|
-
]>;
|
|
102
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
122
103
|
}
|
|
123
|
-
type StorageChoice = {
|
|
124
|
-
type: "local";
|
|
125
|
-
handle: DirectoryWrapper;
|
|
126
|
-
} | {
|
|
127
|
-
type: "remote";
|
|
128
|
-
config: RemoteConfig;
|
|
129
|
-
};
|
|
130
|
-
export declare const getStorageChoice: {
|
|
131
|
-
(): Promise<StorageChoice>;
|
|
132
|
-
reset(): void;
|
|
133
|
-
set(newValue: Promise<StorageChoice>): void;
|
|
134
|
-
};
|
|
135
104
|
export declare const getDirectoryHandle: {
|
|
136
105
|
(): Promise<DirectoryWrapper>;
|
|
137
106
|
reset(): void;
|
|
@@ -169,5 +138,5 @@ export type FileStorage = IStorageRaw & {
|
|
|
169
138
|
folder: NestedFileStorage;
|
|
170
139
|
};
|
|
171
140
|
export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
|
|
141
|
+
export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
|
|
172
142
|
export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
|
|
173
|
-
export {};
|
|
@@ -7,7 +7,7 @@ import { css, isNode } from "typesafecss";
|
|
|
7
7
|
import { IStorageRaw } from "./IStorage";
|
|
8
8
|
import { runInSerial } from "socket-function/src/batching";
|
|
9
9
|
import { getFileStorageIndexDB } from "./IndexedDBFileFolderAPI";
|
|
10
|
-
import {
|
|
10
|
+
import { getRemoteDirectoryHandle, testRemoteConnection, RemoteOptions } from "./remoteFileStorage";
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
|
|
@@ -36,7 +36,13 @@ declare global {
|
|
|
36
36
|
// DO NOT enable this is isNode
|
|
37
37
|
const USE_INDEXED_DB = false;
|
|
38
38
|
|
|
39
|
-
|
|
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.
|
|
43
|
+
export type FileWrapper = {
|
|
44
|
+
readonly kind: "file";
|
|
45
|
+
readonly name: string;
|
|
40
46
|
getFile(): Promise<{
|
|
41
47
|
size: number;
|
|
42
48
|
lastModified: number;
|
|
@@ -51,19 +57,15 @@ type FileWrapper = {
|
|
|
51
57
|
close(): Promise<void>;
|
|
52
58
|
}>;
|
|
53
59
|
};
|
|
54
|
-
type DirectoryWrapper = {
|
|
60
|
+
export type DirectoryWrapper = {
|
|
61
|
+
readonly kind: "directory";
|
|
62
|
+
readonly name: string;
|
|
55
63
|
removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
|
|
56
64
|
getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
|
|
57
65
|
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
|
-
}]>;
|
|
66
|
+
// Each entry IS a handle (file or directory), so a recursive walk keeps working at every level.
|
|
67
|
+
entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
68
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
|
|
67
69
|
};
|
|
68
70
|
|
|
69
71
|
let displayData = observable({
|
|
@@ -105,14 +107,14 @@ function normalizeServerUrl(raw: string): string {
|
|
|
105
107
|
return "https://" + s;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
// One shared
|
|
109
|
-
let
|
|
110
|
-
function
|
|
110
|
+
// One shared remote DirectoryWrapper (and therefore one shared range cache) per remote config.
|
|
111
|
+
let remoteHandle: { key: string; handle: DirectoryWrapper } | undefined;
|
|
112
|
+
function getRemoteHandle(remote: RemoteConfig): DirectoryWrapper {
|
|
111
113
|
const key = remote.url + "\0" + remote.password;
|
|
112
|
-
if (!
|
|
113
|
-
|
|
114
|
+
if (!remoteHandle || remoteHandle.key !== key) {
|
|
115
|
+
remoteHandle = { key, handle: getRemoteDirectoryHandle(remote.url, remote.password) };
|
|
114
116
|
}
|
|
115
|
-
return
|
|
117
|
+
return remoteHandle.handle;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
@observer
|
|
@@ -134,12 +136,18 @@ class DirectoryPrompter extends preact.Component {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
// "Connect to a server" option for the directory prompt: collapses to a button, expands to address +
|
|
137
|
-
// password fields. On connect it
|
|
138
|
-
//
|
|
139
|
-
//
|
|
139
|
+
// password fields. On connect it ACTUALLY connects (testRemoteConnection); only on success does it
|
|
140
|
+
// persist the config and call onConnected, so the caller (getDirectoryHandle) resolves with a working
|
|
141
|
+
// remote handle. Failures are shown to the user (and logged, without the password), never swallowed.
|
|
142
|
+
// `initial` pre-fills + expands the form (used to retry a remembered server that stopped working).
|
|
140
143
|
@observer
|
|
141
|
-
class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteConfig) => void }> {
|
|
142
|
-
private obs = observable({
|
|
144
|
+
class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteConfig) => void; initial?: RemoteConfig }> {
|
|
145
|
+
private obs = observable({
|
|
146
|
+
expanded: !!this.props.initial,
|
|
147
|
+
url: this.props.initial?.url || "",
|
|
148
|
+
password: this.props.initial?.password || "",
|
|
149
|
+
error: "", connecting: false, needsCert: false, showPassword: false,
|
|
150
|
+
});
|
|
143
151
|
private cleanUrl() { return normalizeServerUrl(this.obs.url); }
|
|
144
152
|
private connect = async () => {
|
|
145
153
|
const s = this.obs;
|
|
@@ -149,11 +157,11 @@ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteC
|
|
|
149
157
|
try {
|
|
150
158
|
const url = this.cleanUrl();
|
|
151
159
|
if (!url) { s.error = "Enter a server address."; return; }
|
|
152
|
-
const result = await
|
|
160
|
+
const result = await testRemoteConnection(url, s.password.trim());
|
|
153
161
|
if (result.status === "ok") {
|
|
154
162
|
const config: RemoteConfig = { url, password: s.password.trim() };
|
|
155
163
|
saveRemoteConfig(config); // remember for next session
|
|
156
|
-
this.props.onConnected(config); //
|
|
164
|
+
this.props.onConnected(config); // hand the verified server back to getDirectoryHandle
|
|
157
165
|
return;
|
|
158
166
|
}
|
|
159
167
|
if (result.status === "unauthorized") {
|
|
@@ -167,7 +175,6 @@ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteC
|
|
|
167
175
|
console.error("Remote connect: unreachable", url, "-", result.error);
|
|
168
176
|
}
|
|
169
177
|
} catch (e) {
|
|
170
|
-
// Should be rare (probeRemoteConnection handles its own errors), but never swallow it.
|
|
171
178
|
s.error = "Connection error: " + ((e as Error).message || String(e));
|
|
172
179
|
console.error("Remote connect threw:", e);
|
|
173
180
|
} finally {
|
|
@@ -217,6 +224,8 @@ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteC
|
|
|
217
224
|
export class NodeJSFileHandleWrapper implements FileWrapper {
|
|
218
225
|
constructor(private filePath: string) {
|
|
219
226
|
}
|
|
227
|
+
readonly kind = "file" as const;
|
|
228
|
+
get name() { return path.basename(this.filePath); }
|
|
220
229
|
|
|
221
230
|
async getFile() {
|
|
222
231
|
const stats = await fs.promises.stat(this.filePath);
|
|
@@ -280,6 +289,9 @@ export class NodeJSFileHandleWrapper implements FileWrapper {
|
|
|
280
289
|
export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
281
290
|
constructor(private rootPath: string) {
|
|
282
291
|
}
|
|
292
|
+
readonly kind = "directory" as const;
|
|
293
|
+
get name() { return path.basename(this.rootPath); }
|
|
294
|
+
entries() { return this[Symbol.asyncIterator](); }
|
|
283
295
|
|
|
284
296
|
async removeEntry(key: string, options?: { recursive?: boolean }) {
|
|
285
297
|
const entryPath = path.join(this.rootPath, key);
|
|
@@ -327,63 +339,48 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
327
339
|
return new NodeJSDirectoryHandleWrapper(dirPath);
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, {
|
|
331
|
-
kind: "file";
|
|
332
|
-
name: string;
|
|
333
|
-
getFile(): Promise<FileWrapper>;
|
|
334
|
-
} | {
|
|
335
|
-
kind: "directory";
|
|
336
|
-
name: string;
|
|
337
|
-
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
338
|
-
}]> {
|
|
342
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]> {
|
|
339
343
|
// Ensure directory exists
|
|
340
344
|
await fs.promises.mkdir(this.rootPath, { recursive: true });
|
|
341
345
|
|
|
342
346
|
const entries = await fs.promises.readdir(this.rootPath, { withFileTypes: true });
|
|
343
347
|
|
|
344
348
|
for (const entry of entries) {
|
|
349
|
+
const childPath = path.join(this.rootPath, entry.name);
|
|
345
350
|
if (entry.isFile()) {
|
|
346
|
-
yield [entry.name,
|
|
347
|
-
kind: "file",
|
|
348
|
-
name: entry.name,
|
|
349
|
-
getFile: async () => new NodeJSFileHandleWrapper(path.join(this.rootPath, entry.name))
|
|
350
|
-
}];
|
|
351
|
+
yield [entry.name, new NodeJSFileHandleWrapper(childPath)];
|
|
351
352
|
} else if (entry.isDirectory()) {
|
|
352
|
-
|
|
353
|
-
yield [entry.name, {
|
|
354
|
-
kind: "directory",
|
|
355
|
-
name: entry.name,
|
|
356
|
-
getDirectoryHandle: async (key: string, options?: { create?: boolean }) => {
|
|
357
|
-
return new NodeJSDirectoryHandleWrapper(dirPath).getDirectoryHandle(key, options);
|
|
358
|
-
}
|
|
359
|
-
}];
|
|
353
|
+
yield [entry.name, new NodeJSDirectoryHandleWrapper(childPath)];
|
|
360
354
|
}
|
|
361
355
|
}
|
|
362
356
|
}
|
|
363
357
|
}
|
|
364
358
|
|
|
365
359
|
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
// actually connects to the server and resolves with its config — so when this returns, the storage is
|
|
372
|
-
// ready. No page reload. Blocks until the user chooses (or dismisses, which rejects).
|
|
373
|
-
export const getStorageChoice = lazy(async function getStorageChoice(): Promise<StorageChoice> {
|
|
360
|
+
// Returns the directory handle to use — local (Node / picked folder) OR a remote server, both as the
|
|
361
|
+
// same DirectoryWrapper, so callers don't know or care which it is. A remembered server is VERIFIED
|
|
362
|
+
// (we actually connect) before use; if it no longer works we re-prompt, just like a local folder whose
|
|
363
|
+
// permission was lost. Blocks until ready (or the user dismisses, which rejects).
|
|
364
|
+
export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
|
|
374
365
|
if (isNode()) {
|
|
375
|
-
return
|
|
366
|
+
return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
|
|
376
367
|
}
|
|
368
|
+
|
|
369
|
+
// A server connected in a previous session: only reuse it if it still actually works.
|
|
377
370
|
const savedRemote = loadRemoteConfig();
|
|
378
|
-
if (savedRemote)
|
|
371
|
+
if (savedRemote) {
|
|
372
|
+
const result = await testRemoteConnection(savedRemote.url, savedRemote.password);
|
|
373
|
+
if (result.status === "ok") return getRemoteHandle(savedRemote);
|
|
374
|
+
console.warn(`Saved remote server didn't connect (${result.status})${result.status === "unreachable" ? ": " + result.error : ""} — re-prompting.`);
|
|
375
|
+
}
|
|
379
376
|
|
|
380
377
|
let root = document.createElement("div");
|
|
381
378
|
document.body.appendChild(root);
|
|
382
379
|
preact.render(<DirectoryPrompter />, root);
|
|
383
380
|
try {
|
|
384
|
-
let
|
|
385
|
-
let
|
|
386
|
-
const
|
|
381
|
+
let resolveHandle!: (h: DirectoryWrapper) => void;
|
|
382
|
+
let rejectHandle!: (e: Error) => void;
|
|
383
|
+
const promise = new Promise<DirectoryWrapper>((res, rej) => { resolveHandle = res; rejectHandle = rej; });
|
|
387
384
|
|
|
388
385
|
const pickLocal = async () => {
|
|
389
386
|
try {
|
|
@@ -391,30 +388,33 @@ export const getStorageChoice = lazy(async function getStorageChoice(): Promise<
|
|
|
391
388
|
await handle.requestPermission({ mode: "readwrite" });
|
|
392
389
|
const storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
|
|
393
390
|
localStorage.setItem(getFileAPIKey(), storedId);
|
|
394
|
-
|
|
391
|
+
resolveHandle(handle as any);
|
|
395
392
|
} catch (e) {
|
|
396
393
|
console.error("Directory pick failed/cancelled:", e); // stay on the prompt
|
|
397
394
|
}
|
|
398
395
|
};
|
|
399
|
-
|
|
396
|
+
const onConnected = (config: RemoteConfig) => { saveRemoteConfig(config); resolveHandle(getRemoteHandle(config)); };
|
|
397
|
+
// The three options, rendered fresh each time. If a saved server just failed, pre-fill + expand
|
|
398
|
+
// the connect form so the user can retry or fix it.
|
|
400
399
|
const renderOptions = () => (
|
|
401
400
|
<>
|
|
402
401
|
<button className={css.fontSize(40).pad2(80, 40)} onClick={pickLocal}>Pick Data Directory</button>
|
|
403
|
-
<ServerConnectForm onConnected={
|
|
402
|
+
<ServerConnectForm onConnected={onConnected} initial={savedRemote} />
|
|
404
403
|
<button className={css.fontSize(40).pad2(80, 40)}
|
|
405
|
-
onClick={() =>
|
|
404
|
+
onClick={() => rejectHandle(new Error("User dismissed file system access prompt"))}>Dismiss</button>
|
|
406
405
|
</>
|
|
407
406
|
);
|
|
408
407
|
|
|
409
|
-
// A previously-picked local folder: try to restore
|
|
410
|
-
|
|
408
|
+
// A previously-picked local folder: try to restore it (may need a click). Skipped when a saved
|
|
409
|
+
// server failed — that user wants the server, so go straight to the (pre-filled) prompt.
|
|
410
|
+
const storedId = !savedRemote && localStorage.getItem(getFileAPIKey());
|
|
411
411
|
if (storedId) {
|
|
412
412
|
let doneLoad = false;
|
|
413
413
|
setTimeout(() => { if (!doneLoad) displayData.ui = "Click anywhere to allow file system access"; }, 500);
|
|
414
414
|
try {
|
|
415
415
|
const handle = await tryToLoadPointer(storedId);
|
|
416
416
|
doneLoad = true;
|
|
417
|
-
if (handle) return
|
|
417
|
+
if (handle) return handle;
|
|
418
418
|
} catch (e) {
|
|
419
419
|
doneLoad = true;
|
|
420
420
|
console.error(e);
|
|
@@ -426,37 +426,27 @@ export const getStorageChoice = lazy(async function getStorageChoice(): Promise<
|
|
|
426
426
|
displayData.ui = "Loading...";
|
|
427
427
|
try {
|
|
428
428
|
const h = await tryToLoadPointer(storedId);
|
|
429
|
-
if (h) {
|
|
429
|
+
if (h) { resolveHandle(h); return; }
|
|
430
430
|
} catch (retryError) { console.error("Retry failed:", retryError); }
|
|
431
431
|
displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
|
|
432
432
|
}}>Click to restore file system access</button>
|
|
433
433
|
{renderOptions()}
|
|
434
434
|
</div>
|
|
435
435
|
);
|
|
436
|
-
return await
|
|
436
|
+
return await promise;
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
439
|
}
|
|
440
440
|
displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
|
|
441
|
-
return await
|
|
441
|
+
return await promise;
|
|
442
442
|
} finally {
|
|
443
443
|
preact.render(null, root);
|
|
444
444
|
root.remove();
|
|
445
445
|
}
|
|
446
446
|
});
|
|
447
447
|
|
|
448
|
-
// Local-only directory handle, for callers that don't support remote storage. Throws if the user has
|
|
449
|
-
// configured a remote server (rather than silently doing the wrong thing).
|
|
450
|
-
export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
|
|
451
|
-
const choice = await getStorageChoice();
|
|
452
|
-
if (choice.type === "remote") throw new Error("Storage is configured to use a remote server; a local directory handle isn't available here.");
|
|
453
|
-
return choice.handle;
|
|
454
|
-
});
|
|
455
|
-
|
|
456
448
|
export const getFileStorageNested = cache(async function getFileStorage(path: string): Promise<FileStorage> {
|
|
457
|
-
|
|
458
|
-
if (choice.type === "remote") return getRemoteFactory(choice.config)(path);
|
|
459
|
-
let base = choice.handle;
|
|
449
|
+
let base = await getDirectoryHandle();
|
|
460
450
|
for (let part of path.split("/")) {
|
|
461
451
|
if (!part) continue;
|
|
462
452
|
base = await base.getDirectoryHandle(part, { create: true });
|
|
@@ -473,9 +463,7 @@ export const getFileStorageNested2 = cache(async function getFileStorage(pathStr
|
|
|
473
463
|
}
|
|
474
464
|
base = new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
|
|
475
465
|
} else {
|
|
476
|
-
|
|
477
|
-
if (choice.type === "remote") return getRemoteFactory(choice.config)(pathStr);
|
|
478
|
-
base = choice.handle;
|
|
466
|
+
base = await getDirectoryHandle();
|
|
479
467
|
let dirs: string[] = [];
|
|
480
468
|
let alls: string[] = [];
|
|
481
469
|
for await (const [name, entry] of base) {
|
|
@@ -499,9 +487,8 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
|
|
|
499
487
|
if (USE_INDEXED_DB) {
|
|
500
488
|
return await getFileStorageIndexDB();
|
|
501
489
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return wrapHandle(choice.handle);
|
|
490
|
+
let handle = await getDirectoryHandle();
|
|
491
|
+
return wrapHandle(handle);
|
|
505
492
|
});
|
|
506
493
|
export function resetStorageLocation() {
|
|
507
494
|
localStorage.removeItem(getFileAPIKey());
|
|
@@ -715,6 +702,20 @@ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
|
|
|
715
702
|
};
|
|
716
703
|
}
|
|
717
704
|
|
|
705
|
+
// A StorageFactory backed by a remote server (path -> FileStorage), for code that injects its own
|
|
706
|
+
// storage into BulkDatabase2 rather than going through the directory prompt.
|
|
707
|
+
export function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage> {
|
|
708
|
+
const root = getRemoteDirectoryHandle(url, password, options);
|
|
709
|
+
return async (pathStr: string) => {
|
|
710
|
+
let base: DirectoryWrapper = root;
|
|
711
|
+
for (const part of pathStr.replaceAll("\\", "/").split("/")) {
|
|
712
|
+
if (!part) continue;
|
|
713
|
+
base = await base.getDirectoryHandle(part, { create: true });
|
|
714
|
+
}
|
|
715
|
+
return wrapHandle(base);
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
718
719
|
export async function tryToLoadPointer(pointer: string) {
|
|
719
720
|
let result = await getFileSystemPointer({ pointer });
|
|
720
721
|
if (!result) return;
|
|
@@ -203,6 +203,22 @@ function normalizePassword(p: string): string {
|
|
|
203
203
|
return String(p || "").toLowerCase().replace(/[^a-z]/g, "");
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// The password is generated once and saved (next to the cert, outside the served folder), then reused
|
|
207
|
+
// across restarts — so connected clients keep working after the server bounces, instead of getting a
|
|
208
|
+
// new password every time. Override with the PASSWORD env var.
|
|
209
|
+
function getOrCreatePassword(): string {
|
|
210
|
+
const dir = path.join(os.homedir(), ".sliftutils-remote");
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
const file = path.join(dir, "password.txt");
|
|
213
|
+
try {
|
|
214
|
+
const existing = fs.readFileSync(file, "utf8").trim();
|
|
215
|
+
if (existing) return existing;
|
|
216
|
+
} catch { /* not created yet */ }
|
|
217
|
+
const pw = generatePassword(6);
|
|
218
|
+
fs.writeFileSync(file, pw + "\n", { mode: 0o600 });
|
|
219
|
+
return pw;
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
// Generate (once) and cache a self-signed key + cert via openssl, outside the served folder so its
|
|
207
223
|
// private key is never reachable through the API.
|
|
208
224
|
function getCert(): { key: Buffer; cert: Buffer } {
|
|
@@ -278,7 +294,7 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
278
294
|
if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true });
|
|
279
295
|
const port = options.port ?? 8787;
|
|
280
296
|
const host = options.host || "0.0.0.0";
|
|
281
|
-
const password = options.password ||
|
|
297
|
+
const password = options.password || getOrCreatePassword();
|
|
282
298
|
const normPassword = normalizePassword(password);
|
|
283
299
|
const { key, cert } = getCert();
|
|
284
300
|
|
|
@@ -340,21 +356,17 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
340
356
|
const full = safeResolve(root, relPath);
|
|
341
357
|
if (full === undefined) return sendJson(403, { error: "path escapes root" });
|
|
342
358
|
|
|
359
|
+
// One listing returns every entry with its type, so a directory read is a single round trip.
|
|
343
360
|
if (req.method === "GET" && op === "/list") {
|
|
344
|
-
const wantFolders = url.searchParams.get("folders") === "1";
|
|
345
361
|
let entries: fs.Dirent[];
|
|
346
362
|
try { entries = fs.readdirSync(full, { withFileTypes: true }); }
|
|
347
363
|
catch (e) { return (e as NodeJS.ErrnoException).code === "ENOENT" ? sendJson(200, []) : sendJson(500, { error: (e as Error).message }); }
|
|
348
|
-
return sendJson(200, entries.filter(d =>
|
|
364
|
+
return sendJson(200, entries.filter(d => d.isFile() || d.isDirectory()).map(d => ({ name: d.name, dir: d.isDirectory() })));
|
|
349
365
|
}
|
|
350
366
|
if (req.method === "GET" && op === "/info") {
|
|
351
367
|
let st: fs.Stats;
|
|
352
368
|
try { st = fs.statSync(full); } catch { return sendJson(404, { error: "not found" }); }
|
|
353
|
-
return sendJson(200, { size: st.size, lastModified: st.mtimeMs });
|
|
354
|
-
}
|
|
355
|
-
if (req.method === "GET" && op === "/hasDir") {
|
|
356
|
-
let st: fs.Stats; try { st = fs.statSync(full); } catch { return sendJson(200, { exists: false }); }
|
|
357
|
-
return sendJson(200, { exists: st.isDirectory() });
|
|
369
|
+
return sendJson(200, { size: st.size, lastModified: st.mtimeMs, dir: st.isDirectory() });
|
|
358
370
|
}
|
|
359
371
|
if (req.method === "GET" && op === "/read") {
|
|
360
372
|
let st: fs.Stats;
|
|
@@ -377,19 +389,11 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
377
389
|
recordAccess(clientIp(req), "write", relPath, body.length);
|
|
378
390
|
return sendJson(200, { ok: true });
|
|
379
391
|
}
|
|
392
|
+
// Removes a file or a directory (recursively); missing is fine.
|
|
380
393
|
if (req.method === "DELETE" && op === "/remove") {
|
|
381
|
-
try { fs.unlinkSync(full); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") return sendJson(500, { error: (e as Error).message }); }
|
|
382
|
-
return sendJson(200, { ok: true });
|
|
383
|
-
}
|
|
384
|
-
if (req.method === "DELETE" && op === "/removeDir") {
|
|
385
394
|
try { fs.rmSync(full, { recursive: true, force: true }); } catch (e) { return sendJson(500, { error: (e as Error).message }); }
|
|
386
395
|
return sendJson(200, { ok: true });
|
|
387
396
|
}
|
|
388
|
-
if (req.method === "POST" && op === "/reset") {
|
|
389
|
-
try { for (const name of fs.readdirSync(full)) fs.rmSync(path.join(full, name), { recursive: true, force: true }); }
|
|
390
|
-
catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") return sendJson(500, { error: (e as Error).message }); }
|
|
391
|
-
return sendJson(200, { ok: true });
|
|
392
|
-
}
|
|
393
397
|
return sendJson(404, { error: "unknown endpoint" });
|
|
394
398
|
} catch (e) {
|
|
395
399
|
try { sendJson(500, { error: String((e as Error)?.message || e) }); } catch { /* response already sent */ }
|
|
@@ -1,41 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/// <reference types="node" />
|
|
4
|
-
import https from "https";
|
|
5
|
-
import type { FileStorage } from "./FileFolderAPI";
|
|
6
|
-
export type RemoteFileStorageOptions = {
|
|
1
|
+
import type { DirectoryWrapper } from "./FileFolderAPI";
|
|
2
|
+
export type RemoteOptions = {
|
|
7
3
|
chunkBytes?: number;
|
|
8
4
|
cacheBytes?: number;
|
|
9
5
|
latencyMs?: number;
|
|
10
|
-
|
|
11
|
-
type Connection = {
|
|
12
|
-
url: string;
|
|
13
|
-
password: string;
|
|
14
|
-
latencyMs: number;
|
|
15
|
-
agent: https.Agent | undefined;
|
|
16
|
-
cache: RangeCache;
|
|
17
|
-
stats: {
|
|
6
|
+
stats?: {
|
|
18
7
|
requestCount: number;
|
|
19
8
|
bytesFetched: number;
|
|
20
9
|
};
|
|
21
10
|
};
|
|
22
|
-
declare
|
|
23
|
-
|
|
24
|
-
private budget;
|
|
25
|
-
private chunks;
|
|
26
|
-
private bytes;
|
|
27
|
-
constructor(chunkBytes: number, budget: number);
|
|
28
|
-
private key;
|
|
29
|
-
private peek;
|
|
30
|
-
private store;
|
|
31
|
-
invalidate(path: string): void;
|
|
32
|
-
getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined>;
|
|
33
|
-
}
|
|
34
|
-
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & {
|
|
35
|
-
stats: Connection["stats"];
|
|
36
|
-
};
|
|
37
|
-
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
38
|
-
export type RemoteProbeResult = {
|
|
11
|
+
export declare function getRemoteDirectoryHandle(url: string, password: string, options?: RemoteOptions): DirectoryWrapper;
|
|
12
|
+
export type RemoteConnectResult = {
|
|
39
13
|
status: "ok";
|
|
40
14
|
} | {
|
|
41
15
|
status: "unauthorized";
|
|
@@ -43,5 +17,4 @@ export type RemoteProbeResult = {
|
|
|
43
17
|
status: "unreachable";
|
|
44
18
|
error: string;
|
|
45
19
|
};
|
|
46
|
-
export declare function
|
|
47
|
-
export {};
|
|
20
|
+
export declare function testRemoteConnection(url: string, password: string, options?: RemoteOptions): Promise<RemoteConnectResult>;
|
|
@@ -1,99 +1,140 @@
|
|
|
1
1
|
import { isNode } from "typesafecss";
|
|
2
2
|
import https from "https";
|
|
3
|
-
import type {
|
|
3
|
+
import type { DirectoryWrapper, FileWrapper } from "./FileFolderAPI";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// A remote server (remoteFileServer.js) exposed as a DirectoryWrapper — the SAME interface as the
|
|
6
|
+
// Node.js and File-System-Access handles. So getDirectoryHandle can return one of these and everything
|
|
7
|
+
// downstream (wrapHandle, navigation, BulkDatabase2) works unchanged; nothing else knows it's remote.
|
|
8
|
+
//
|
|
9
|
+
// Files here are immutable (written once) or append-only, so the range cache (raw compressed bytes, in
|
|
10
|
+
// ~1MB aligned chunks) is always valid — over the network latency dominates, so reading 1MB costs about
|
|
11
|
+
// the same as 64KB and far fewer round trips wins.
|
|
9
12
|
|
|
10
13
|
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
14
|
const DEFAULT_CHUNK_BYTES = 1024 * 1024;
|
|
15
15
|
const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024;
|
|
16
16
|
|
|
17
|
-
export type
|
|
18
|
-
// Bytes per cached chunk (aligned). Reads coalesce/serve from these.
|
|
17
|
+
export type RemoteOptions = {
|
|
19
18
|
chunkBytes?: number;
|
|
20
|
-
// Max total bytes held in the range cache (LRU eviction).
|
|
21
19
|
cacheBytes?: number;
|
|
22
|
-
//
|
|
23
|
-
|
|
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 };
|
|
20
|
+
latencyMs?: number; // artificial per-request delay, for simulating network latency in tests
|
|
21
|
+
stats?: { requestCount: number; bytesFetched: number };
|
|
34
22
|
};
|
|
35
23
|
|
|
24
|
+
function enoent(p: string): Error {
|
|
25
|
+
return Object.assign(new Error(`File not found: ${p}`), { code: "ENOENT", name: "NotFoundError" });
|
|
26
|
+
}
|
|
27
|
+
function toArrayBuffer(b: Buffer): ArrayBuffer {
|
|
28
|
+
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;
|
|
29
|
+
}
|
|
36
30
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
type Stat = { size: number; lastModified: number; dir: boolean };
|
|
33
|
+
|
|
34
|
+
// A stat is cached this long, so the burst of size lookups during a single read pass is one request per
|
|
35
|
+
// file, not one per block. Short enough that an append by another writer shows up promptly; our own
|
|
36
|
+
// writes invalidate it immediately.
|
|
37
|
+
const INFO_TTL_MS = 2000;
|
|
38
|
+
|
|
39
|
+
class Connection {
|
|
40
|
+
private agent = isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
|
41
|
+
private cache: RangeCache;
|
|
42
|
+
private infoCache = new Map<string, { stat: Stat | undefined; at: number }>();
|
|
43
|
+
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
|
+
this.url = url.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
// One HTTP request. Node uses the https module (self-signed cert → verification disabled); the
|
|
49
|
+
// browser uses fetch (the user has accepted the cert). Returns the raw status + body bytes.
|
|
50
|
+
async request(method: string, op: string, params: Record<string, string>, body?: Buffer): Promise<{ status: number; body: Buffer }> {
|
|
51
|
+
if (this.opts.latencyMs) await sleep(this.opts.latencyMs);
|
|
52
|
+
if (this.opts.stats) this.opts.stats.requestCount++;
|
|
53
|
+
const qs = new URLSearchParams(params).toString();
|
|
54
|
+
const fullUrl = this.url + op + (qs ? "?" + qs : "");
|
|
55
|
+
const headers: Record<string, string> = { authorization: "Bearer " + this.password };
|
|
56
|
+
if (body) headers["content-type"] = "application/octet-stream";
|
|
57
|
+
if (isNode()) {
|
|
58
|
+
return await new Promise((resolve, reject) => {
|
|
59
|
+
const u = new URL(fullUrl);
|
|
60
|
+
const req = https.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers, agent: this.agent }, res => {
|
|
61
|
+
const chunks: Buffer[] = [];
|
|
62
|
+
res.on("data", d => chunks.push(d as Buffer));
|
|
63
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
|
|
64
|
+
});
|
|
65
|
+
req.on("error", reject);
|
|
66
|
+
if (body) req.write(body);
|
|
67
|
+
req.end();
|
|
57
68
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
69
|
+
}
|
|
70
|
+
const res = await fetch(fullUrl, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
71
|
+
return { status: res.status, body: Buffer.from(await res.arrayBuffer()) };
|
|
62
72
|
}
|
|
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
73
|
|
|
68
|
-
async
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
async stat(path: string): Promise<Stat | undefined> {
|
|
75
|
+
const cached = this.infoCache.get(path);
|
|
76
|
+
if (cached && Date.now() - cached.at < INFO_TTL_MS) return cached.stat;
|
|
77
|
+
const r = await this.request("GET", "/info", { path });
|
|
78
|
+
let stat: Stat | undefined;
|
|
79
|
+
if (r.status === 404) stat = undefined;
|
|
80
|
+
else if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
|
|
81
|
+
else stat = JSON.parse(r.body.toString("utf8")) as Stat;
|
|
82
|
+
this.infoCache.set(path, { stat, at: Date.now() });
|
|
83
|
+
return stat;
|
|
84
|
+
}
|
|
85
|
+
async list(path: string): Promise<{ name: string; dir: boolean }[]> {
|
|
86
|
+
const r = await this.request("GET", "/list", { path });
|
|
87
|
+
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
88
|
+
return JSON.parse(r.body.toString("utf8"));
|
|
89
|
+
}
|
|
90
|
+
async read(path: string, start: number, end: number): Promise<Buffer> {
|
|
91
|
+
return (await this.cache.read(this, path, start, end)) ?? EMPTY;
|
|
92
|
+
}
|
|
93
|
+
async readServer(path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
94
|
+
const r = await this.request("GET", "/read", { path, start: String(start), end: String(end) });
|
|
95
|
+
if (r.status === 404) return undefined;
|
|
96
|
+
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
|
+
return r.body;
|
|
99
|
+
}
|
|
100
|
+
async append(path: string, body: Buffer): Promise<void> {
|
|
101
|
+
const r = await this.request("PUT", "/append", { path }, body);
|
|
102
|
+
if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
|
|
103
|
+
// Append-only keeps existing bytes, so cached chunks stay valid; just the size changed.
|
|
104
|
+
this.infoCache.delete(path);
|
|
105
|
+
}
|
|
106
|
+
async set(path: string, body: Buffer): Promise<void> {
|
|
107
|
+
const r = await this.request("PUT", "/set", { path }, body);
|
|
108
|
+
if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
|
|
109
|
+
this.cache.invalidate(path);
|
|
110
|
+
this.infoCache.delete(path);
|
|
111
|
+
}
|
|
112
|
+
async remove(path: string): Promise<void> {
|
|
113
|
+
const r = await this.request("DELETE", "/remove", { path });
|
|
114
|
+
if (r.status !== 200) throw new Error(`remote remove failed (${r.status})`);
|
|
115
|
+
this.cache.invalidate(path);
|
|
116
|
+
this.infoCache.delete(path);
|
|
117
|
+
}
|
|
74
118
|
}
|
|
75
119
|
|
|
76
|
-
// Caches the longest-known prefix of each aligned chunk per path.
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// that needs MORE than we have refetches (and picks up appended bytes). It never distinguishes file
|
|
80
|
-
// types — it only does range reads.
|
|
120
|
+
// Caches the longest-known prefix of each aligned chunk per path. Valid because files are immutable or
|
|
121
|
+
// append-only (the bytes at an offset never change). It only does range reads — it doesn't care what
|
|
122
|
+
// the files are.
|
|
81
123
|
class RangeCache {
|
|
82
|
-
private chunks = new Map<string, Buffer>(); //
|
|
124
|
+
private chunks = new Map<string, Buffer>(); // path + "" + chunkIndex, insertion-ordered (LRU)
|
|
83
125
|
private bytes = 0;
|
|
84
126
|
constructor(private chunkBytes: number, private budget: number) { }
|
|
85
|
-
|
|
86
127
|
private key(path: string, c: number) { return path + "" + c; }
|
|
87
128
|
private peek(path: string, c: number): Buffer | undefined {
|
|
88
129
|
const k = this.key(path, c);
|
|
89
130
|
const v = this.chunks.get(k);
|
|
90
|
-
if (v) { this.chunks.delete(k); this.chunks.set(k, v); }
|
|
131
|
+
if (v) { this.chunks.delete(k); this.chunks.set(k, v); }
|
|
91
132
|
return v;
|
|
92
133
|
}
|
|
93
134
|
private store(path: string, c: number, buf: Buffer) {
|
|
94
135
|
const k = this.key(path, c);
|
|
95
136
|
const ex = this.chunks.get(k);
|
|
96
|
-
if (ex && ex.length >= buf.length) { this.chunks.delete(k); this.chunks.set(k, ex); return; }
|
|
137
|
+
if (ex && ex.length >= buf.length) { this.chunks.delete(k); this.chunks.set(k, ex); return; }
|
|
97
138
|
if (ex) this.bytes -= ex.length;
|
|
98
139
|
this.chunks.delete(k);
|
|
99
140
|
this.chunks.set(k, buf);
|
|
@@ -106,17 +147,13 @@ class RangeCache {
|
|
|
106
147
|
}
|
|
107
148
|
invalidate(path: string) {
|
|
108
149
|
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
|
-
}
|
|
150
|
+
for (const k of [...this.chunks.keys()]) if (k.startsWith(prefix)) { this.bytes -= (this.chunks.get(k) as Buffer).length; this.chunks.delete(k); }
|
|
112
151
|
}
|
|
113
|
-
|
|
114
|
-
async getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
152
|
+
async read(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
115
153
|
if (end <= start) return EMPTY;
|
|
116
154
|
const CHUNK = this.chunkBytes;
|
|
117
155
|
const firstChunk = Math.floor(start / CHUNK);
|
|
118
156
|
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
157
|
let fetchFrom = -1, fetchTo = -1;
|
|
121
158
|
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
122
159
|
const cStart = c * CHUNK;
|
|
@@ -125,11 +162,11 @@ class RangeCache {
|
|
|
125
162
|
if (!have || have.length < needEnd) { if (fetchFrom < 0) fetchFrom = c; fetchTo = c; }
|
|
126
163
|
}
|
|
127
164
|
if (fetchFrom >= 0) {
|
|
128
|
-
const bytes = await
|
|
129
|
-
if (bytes === undefined) return undefined;
|
|
165
|
+
const bytes = await conn.readServer(path, fetchFrom * CHUNK, (fetchTo + 1) * CHUNK);
|
|
166
|
+
if (bytes === undefined) return undefined;
|
|
130
167
|
for (let c = fetchFrom; c <= fetchTo; c++) {
|
|
131
168
|
const off = (c - fetchFrom) * CHUNK;
|
|
132
|
-
if (off >= bytes.length) break;
|
|
169
|
+
if (off >= bytes.length) break;
|
|
133
170
|
this.store(path, c, bytes.subarray(off, Math.min(off + CHUNK, bytes.length)));
|
|
134
171
|
}
|
|
135
172
|
}
|
|
@@ -139,122 +176,112 @@ class RangeCache {
|
|
|
139
176
|
const from = Math.max(start, cStart) - cStart;
|
|
140
177
|
const to = Math.min(end, cStart + CHUNK) - cStart;
|
|
141
178
|
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
|
-
}
|
|
179
|
+
if (!chunk || chunk.length < to) { if (chunk && chunk.length > from) parts.push(chunk.subarray(from, chunk.length)); break; }
|
|
147
180
|
parts.push(chunk.subarray(from, to));
|
|
148
181
|
}
|
|
149
182
|
return parts.length === 1 ? parts[0] : Buffer.concat(parts);
|
|
150
183
|
}
|
|
151
184
|
}
|
|
152
185
|
|
|
153
|
-
|
|
154
|
-
|
|
186
|
+
const joinPath = (base: string, key: string) => (base ? base + "/" + key : key);
|
|
187
|
+
const baseName = (p: string) => p.split("/").filter(Boolean).pop() || "";
|
|
155
188
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
class RemoteFileWrapper implements FileWrapper {
|
|
190
|
+
// `stat` is supplied when the parent already statted us (avoids a second /info). `createIntent` means
|
|
191
|
+
// this was opened with create:true, so a missing file reads as empty (it'll be created on write).
|
|
192
|
+
constructor(private conn: Connection, private filePath: string, private stat?: Stat, private createIntent = false) { }
|
|
193
|
+
// Mirror the native FileSystemFileHandle shape so code written against it works unchanged.
|
|
194
|
+
readonly kind = "file" as const;
|
|
195
|
+
get name() { return baseName(this.filePath); }
|
|
196
|
+
async getFile() {
|
|
197
|
+
let stat = this.stat ?? await this.conn.stat(this.filePath);
|
|
198
|
+
if (!stat) {
|
|
199
|
+
if (!this.createIntent) throw enoent(this.filePath);
|
|
200
|
+
stat = { size: 0, lastModified: Date.now(), dir: false };
|
|
201
|
+
}
|
|
202
|
+
const conn = this.conn, filePath = this.filePath, size = stat.size;
|
|
203
|
+
return {
|
|
204
|
+
size, lastModified: stat.lastModified,
|
|
205
|
+
async arrayBuffer() { return toArrayBuffer(await conn.read(filePath, 0, size)); },
|
|
206
|
+
slice(start: number, end: number) {
|
|
207
|
+
return { async arrayBuffer() { return toArrayBuffer(await conn.read(filePath, start, end)); } };
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async createWritable(config?: { keepExistingData?: boolean }) {
|
|
212
|
+
const conn = this.conn, filePath = this.filePath, append = !!config?.keepExistingData;
|
|
213
|
+
const chunks: Buffer[] = [];
|
|
214
|
+
return {
|
|
215
|
+
async seek() { /* the server appends to the end / overwrites the whole file; offset unused */ },
|
|
216
|
+
async write(value: Buffer) { chunks.push(value); },
|
|
217
|
+
async close() {
|
|
218
|
+
const body = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
|
|
219
|
+
if (append) await conn.append(filePath, body); else await conn.set(filePath, body);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
175
224
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
conn.cache.invalidate(basePath);
|
|
213
|
-
},
|
|
214
|
-
folder,
|
|
215
|
-
};
|
|
225
|
+
class RemoteDirectoryWrapper implements DirectoryWrapper {
|
|
226
|
+
constructor(private conn: Connection, private dirPath: string) { }
|
|
227
|
+
// Mirror the native FileSystemDirectoryHandle shape (name/kind/entries) so code written against the
|
|
228
|
+
// native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
|
|
229
|
+
readonly kind = "directory" as const;
|
|
230
|
+
get name() { return baseName(this.dirPath); }
|
|
231
|
+
async removeEntry(key: string): Promise<void> {
|
|
232
|
+
await this.conn.remove(joinPath(this.dirPath, key));
|
|
233
|
+
}
|
|
234
|
+
async getFileHandle(key: string, options?: { create?: boolean }): Promise<RemoteFileWrapper> {
|
|
235
|
+
const p = joinPath(this.dirPath, key);
|
|
236
|
+
if (options?.create) return new RemoteFileWrapper(this.conn, p, undefined, true);
|
|
237
|
+
const stat = await this.conn.stat(p); // matches the File API: throw if missing
|
|
238
|
+
if (!stat || stat.dir) throw enoent(p);
|
|
239
|
+
return new RemoteFileWrapper(this.conn, p, stat, false);
|
|
240
|
+
}
|
|
241
|
+
async getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<RemoteDirectoryWrapper> {
|
|
242
|
+
const p = joinPath(this.dirPath, key);
|
|
243
|
+
if (!options?.create) {
|
|
244
|
+
const stat = await this.conn.stat(p);
|
|
245
|
+
if (!stat || !stat.dir) throw enoent(p);
|
|
246
|
+
}
|
|
247
|
+
return new RemoteDirectoryWrapper(this.conn, p); // dirs are created lazily on first write
|
|
248
|
+
}
|
|
249
|
+
// Each entry is itself a real handle (a sub-wrapper), so a recursive walk over .entries() keeps
|
|
250
|
+
// working at every level — exactly like the native API.
|
|
251
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, RemoteFileWrapper | RemoteDirectoryWrapper]> {
|
|
252
|
+
const entries = await this.conn.list(this.dirPath);
|
|
253
|
+
for (const e of entries) {
|
|
254
|
+
const childPath = joinPath(this.dirPath, e.name);
|
|
255
|
+
yield [e.name, e.dir ? new RemoteDirectoryWrapper(this.conn, childPath) : new RemoteFileWrapper(this.conn, childPath)];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
entries() { return this[Symbol.asyncIterator](); }
|
|
259
|
+
keys() { return mapAsync(this[Symbol.asyncIterator](), ([name]) => name); }
|
|
260
|
+
values() { return mapAsync(this[Symbol.asyncIterator](), ([, handle]) => handle); }
|
|
216
261
|
}
|
|
217
262
|
|
|
218
|
-
|
|
263
|
+
async function* mapAsync<T, U>(it: AsyncIterableIterator<T>, fn: (v: T) => U): AsyncIterableIterator<U> {
|
|
264
|
+
for await (const v of it) yield fn(v);
|
|
265
|
+
}
|
|
219
266
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
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;
|
|
267
|
+
// A DirectoryWrapper rooted at a remote server. Drop-in for the Node / File-API handles.
|
|
268
|
+
export function getRemoteDirectoryHandle(url: string, password: string, options: RemoteOptions = {}): DirectoryWrapper {
|
|
269
|
+
return new RemoteDirectoryWrapper(new Connection(url, password, options), "");
|
|
236
270
|
}
|
|
237
271
|
|
|
238
|
-
export type
|
|
239
|
-
| { status: "ok" }
|
|
240
|
-
| { status: "unauthorized" }
|
|
241
|
-
// Couldn't reach the server at all — in the browser this is usually the self-signed certificate not
|
|
242
|
-
// being trusted yet (fetch throws with no detail), but also covers a wrong URL / server down / CORS.
|
|
243
|
-
| { status: "unreachable"; error: string };
|
|
272
|
+
export type RemoteConnectResult = { status: "ok" } | { status: "unauthorized" } | { status: "unreachable"; error: string };
|
|
244
273
|
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
export async function
|
|
274
|
+
// Verifies a server is reachable and the password works — by actually listing the root. Distinguishes
|
|
275
|
+
// "connected" / "wrong password" / "couldn't reach it" (the last usually meaning the self-signed cert
|
|
276
|
+
// isn't trusted yet in the browser, since fetch failures carry no detail).
|
|
277
|
+
export async function testRemoteConnection(url: string, password: string, options: RemoteOptions = {}): Promise<RemoteConnectResult> {
|
|
278
|
+
const conn = new Connection(url, password, options);
|
|
249
279
|
try {
|
|
250
|
-
|
|
251
|
-
return { status: "ok" };
|
|
280
|
+
const r = await conn.request("GET", "/list", { path: "" });
|
|
281
|
+
if (r.status === 200) return { status: "ok" };
|
|
282
|
+
if (r.status === 401) return { status: "unauthorized" };
|
|
283
|
+
return { status: "unreachable", error: `server returned ${r.status}` };
|
|
252
284
|
} catch (e) {
|
|
253
|
-
|
|
254
|
-
// Our client throws "remote ... failed (NNN)" when it actually got an HTTP response (so the cert
|
|
255
|
-
// is trusted); a thrown fetch with no status means we never connected.
|
|
256
|
-
const m = msg.match(/\((\d{3})\)/);
|
|
257
|
-
if (m) return m[1] === "401" ? { status: "unauthorized" } : { status: "unreachable", error: msg };
|
|
258
|
-
return { status: "unreachable", error: msg };
|
|
285
|
+
return { status: "unreachable", error: (e as Error)?.message || String(e) };
|
|
259
286
|
}
|
|
260
287
|
}
|