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 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
- [Symbol.asyncIterator](): AsyncIterableIterator<[
1372
- string,
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
- /// <reference types="node" />
1881
- /// <reference types="node" />
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 class RangeCache {
1902
- private chunkBytes;
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 probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult>;
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,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.10",
3
+ "version": "1.4.12",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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
- [Symbol.asyncIterator](): AsyncIterableIterator<[
58
- string,
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 { getRemoteFileStorage, probeRemoteConnection } from "./remoteFileStorage";
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
- type FileWrapper = {
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
- [Symbol.asyncIterator](): AsyncIterableIterator<[string, {
59
- kind: "file";
60
- name: string;
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 factory (and therefore one shared range cache) per remote config.
109
- let remoteFactory: { key: string; factory: ReturnType<typeof getRemoteFileStorage> } | undefined;
110
- function getRemoteFactory(remote: RemoteConfig) {
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 (!remoteFactory || remoteFactory.key !== key) {
113
- remoteFactory = { key, factory: getRemoteFileStorage(remote.url, remote.password) };
114
+ if (!remoteHandle || remoteHandle.key !== key) {
115
+ remoteHandle = { key, handle: getRemoteDirectoryHandle(remote.url, remote.password) };
114
116
  }
115
- return remoteFactory.factory;
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 actually connects to the server; on success it persists the config and
138
- // calls onConnected so the caller (getStorageChoice) resolves with the live remote storage — no reload.
139
- // Connection failures are shown to the user (and logged, without the password), never swallowed.
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({ expanded: false, url: "", password: "", error: "", connecting: false, needsCert: false, showPassword: false });
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 probeRemoteConnection(url, s.password.trim());
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); // resolve the pending storage request now (no reload)
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
- const dirPath = path.join(this.rootPath, entry.name);
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
- // What storage the user chose: a local directory handle, or a remote server config.
367
- type StorageChoice = { type: "local"; handle: DirectoryWrapper } | { type: "remote"; config: RemoteConfig };
368
-
369
- // Resolves (once) what storage to use. If a remote server was connected in a previous session we use it
370
- // straight away; otherwise we prompt (Pick directory / Connect to a server / Dismiss). The Connect path
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 { type: "local", handle: new NodeJSDirectoryHandleWrapper(path.resolve("./data/")) };
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) return { type: "remote", config: 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 resolveChoice!: (c: StorageChoice) => void;
385
- let rejectChoice!: (e: Error) => void;
386
- const choicePromise = new Promise<StorageChoice>((res, rej) => { resolveChoice = res; rejectChoice = rej; });
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
- resolveChoice({ type: "local", handle: handle as any });
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
- // The three options, rendered fresh each time (so reused-vnode issues can't arise).
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={config => resolveChoice({ type: "remote", config })} />
402
+ <ServerConnectForm onConnected={onConnected} initial={savedRemote} />
404
403
  <button className={css.fontSize(40).pad2(80, 40)}
405
- onClick={() => rejectChoice(new Error("User dismissed file system access prompt"))}>Dismiss</button>
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 its handle (may need a click for activation).
410
- const storedId = localStorage.getItem(getFileAPIKey());
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 { type: "local", handle };
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) { resolveChoice({ type: "local", handle: h }); return; }
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 choicePromise;
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 choicePromise;
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
- const choice = await getStorageChoice();
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
- const choice = await getStorageChoice();
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
- const choice = await getStorageChoice();
503
- if (choice.type === "remote") return getRemoteFactory(choice.config)("");
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 || generatePassword(6);
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 => wantFolders ? d.isDirectory() : d.isFile()).map(d => d.name));
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
- /// <reference types="node" />
2
- /// <reference types="node" />
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 class RangeCache {
23
- private chunkBytes;
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 probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult>;
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 { FileStorage, NestedFileStorage } from "./FileFolderAPI";
3
+ import type { DirectoryWrapper, FileWrapper } from "./FileFolderAPI";
4
4
 
5
- // Client for remoteFileServer.js: exposes a remote folder as a FileStorage (so BulkDatabase2 runs over
6
- // the network unchanged). One HTTP round trip per operation. A pure data-level range cache (below)
7
- // fetches in large aligned chunks and reuses them, because over the network latency dominates reading
8
- // 1MB costs about the same as 64KB, so fewer round trips wins.
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 RemoteFileStorageOptions = {
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
- // Artificial per-request delay, for simulating network latency in tests.
23
- latencyMs?: number;
24
- };
25
-
26
- type Connection = {
27
- url: string;
28
- password: string;
29
- latencyMs: number;
30
- agent: https.Agent | undefined;
31
- cache: RangeCache;
32
- // Observable stats (handy for tests / diagnostics).
33
- stats: { requestCount: number; bytesFetched: number };
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
- // One HTTP request. Node uses the https module (self-signed cert → verification disabled); the browser
39
- // uses fetch (the user accepts the self-signed cert once). Returns the raw status + body bytes.
40
- async function httpRequest(conn: Connection, method: string, op: string, params: Record<string, string>, body?: Buffer): Promise<{ status: number; body: Buffer }> {
41
- if (conn.latencyMs > 0) await sleep(conn.latencyMs);
42
- conn.stats.requestCount++;
43
- const qs = new URLSearchParams(params).toString();
44
- const fullUrl = conn.url.replace(/\/+$/, "") + op + (qs ? "?" + qs : "");
45
- const headers: Record<string, string> = { authorization: "Bearer " + conn.password };
46
- if (body) headers["content-type"] = "application/octet-stream";
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
- if (isNode()) {
49
- return await new Promise((resolve, reject) => {
50
- const u = new URL(fullUrl);
51
- const req = https.request({
52
- hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers, agent: conn.agent,
53
- }, res => {
54
- const chunks: Buffer[] = [];
55
- res.on("data", d => chunks.push(d as Buffer));
56
- res.on("end", () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
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
- req.on("error", reject);
59
- if (body) req.write(body);
60
- req.end();
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 function readRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
69
- const r = await httpRequest(conn, "GET", "/read", { path, start: String(start), end: String(end) });
70
- if (r.status === 404) return undefined;
71
- if (r.status !== 200) throw new Error(`remote read failed (${r.status}): ${r.body.toString("utf8").slice(0, 200)}`);
72
- conn.stats.bytesFetched += r.body.length;
73
- return r.body;
74
+ 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. Safe because every file here is either
77
- // immutable (bulk files, written once under a unique name) or append-only (stream files): the bytes at a
78
- // given offset never change, only more are added. So a cached [0, n) of a chunk is always valid; a read
79
- // that needs MORE than we have refetches (and picks up appended bytes). It never distinguishes file
80
- // types — it only does range reads.
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>(); // key: path + "" + chunkIndex (insertion-ordered LRU)
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); } // bump to most-recent
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; } // keep longer prefix
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 readRange(conn, path, fetchFrom * CHUNK, (fetchTo + 1) * CHUNK);
129
- if (bytes === undefined) return undefined; // file missing
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; // hit EOF — no bytes for this chunk
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
- function makeStorage(conn: Connection, basePath: string): FileStorage {
154
- const rel = (key: string) => basePath ? basePath + "/" + key : key;
186
+ const joinPath = (base: string, key: string) => (base ? base + "/" + key : key);
187
+ const baseName = (p: string) => p.split("/").filter(Boolean).pop() || "";
155
188
 
156
- const folder: NestedFileStorage = {
157
- async hasKey(key: string): Promise<boolean> {
158
- const r = await httpRequest(conn, "GET", "/hasDir", { path: rel(key) });
159
- if (r.status !== 200) return false;
160
- return !!JSON.parse(r.body.toString("utf8")).exists;
161
- },
162
- async getStorage(key: string): Promise<FileStorage> {
163
- return makeStorage(conn, rel(key));
164
- },
165
- async removeStorage(key: string): Promise<void> {
166
- await httpRequest(conn, "DELETE", "/removeDir", { path: rel(key) });
167
- conn.cache.invalidate(rel(key));
168
- },
169
- async getKeys(): Promise<string[]> {
170
- const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: "1" });
171
- if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
172
- return JSON.parse(r.body.toString("utf8"));
173
- },
174
- };
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
- return {
177
- async getInfo(key: string) {
178
- const r = await httpRequest(conn, "GET", "/info", { path: rel(key) });
179
- if (r.status === 404) return undefined;
180
- if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
181
- return JSON.parse(r.body.toString("utf8")) as { size: number; lastModified: number };
182
- },
183
- async get(key: string) {
184
- const info = await this.getInfo(key);
185
- if (!info) return undefined;
186
- return this.getRange(key, { start: 0, end: info.size });
187
- },
188
- async getRange(key: string, config: { start: number; end: number }) {
189
- return conn.cache.getRange(conn, rel(key), config.start, config.end);
190
- },
191
- async append(key: string, value: Buffer) {
192
- const r = await httpRequest(conn, "PUT", "/append", { path: rel(key) }, value);
193
- if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
194
- // No invalidation needed: append-only files keep their prefix; a read past it refetches.
195
- },
196
- async set(key: string, value: Buffer) {
197
- const r = await httpRequest(conn, "PUT", "/set", { path: rel(key) }, value);
198
- if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
199
- conn.cache.invalidate(rel(key));
200
- },
201
- async remove(key: string) {
202
- await httpRequest(conn, "DELETE", "/remove", { path: rel(key) });
203
- conn.cache.invalidate(rel(key));
204
- },
205
- async getKeys(includeFolders: boolean = false) {
206
- const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: includeFolders ? "1" : "0" });
207
- if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
208
- return JSON.parse(r.body.toString("utf8"));
209
- },
210
- async reset() {
211
- await httpRequest(conn, "POST", "/reset", { path: basePath });
212
- conn.cache.invalidate(basePath);
213
- },
214
- folder,
215
- };
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
- export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & { stats: Connection["stats"] };
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
- // Returns a StorageFactory (path -> FileStorage) backed by a remoteFileServer.js instance. Drop-in for
221
- // getFileStorageNested2 in BulkDatabase2. `password` is the 6-word password the server printed.
222
- export function getRemoteFileStorage(url: string, password: string, options: RemoteFileStorageOptions = {}): RemoteStorageFactory {
223
- const conn: Connection = {
224
- url,
225
- password,
226
- latencyMs: options.latencyMs || 0,
227
- // Self-signed cert: skip verification in Node. (The browser path uses fetch, where the user has
228
- // already accepted the cert.) The password is what authorizes access.
229
- agent: isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined,
230
- cache: new RangeCache(options.chunkBytes || DEFAULT_CHUNK_BYTES, options.cacheBytes || DEFAULT_CACHE_BYTES),
231
- stats: { requestCount: 0, bytesFetched: 0 },
232
- };
233
- const factory = ((path: string) => Promise.resolve(makeStorage(conn, path.replace(/^\/+|\/+$/g, "")))) as RemoteStorageFactory;
234
- factory.stats = conn.stats;
235
- return factory;
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 RemoteProbeResult =
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
- // Probes a remote server: distinguishes "connected" from "wrong password" from "couldn't reach it". The
246
- // browser can't tell a cert-distrust from other network failures (fetch just throws), so the UI treats
247
- // `unreachable` as "you probably need to accept the self-signed certificate first".
248
- export async function probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult> {
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
- await (await getRemoteFileStorage(url, password)("")).getKeys();
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
- const msg = (e as Error)?.message || String(e);
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
  }