sliftutils 1.4.12 → 1.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1364,6 +1364,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1364
1364
  export type DirectoryWrapper = {
1365
1365
  readonly kind: "directory";
1366
1366
  readonly name: string;
1367
+ readonly fullPath?: string;
1367
1368
  removeEntry(key: string, options?: {
1368
1369
  recursive?: boolean;
1369
1370
  }): Promise<void>;
@@ -1403,6 +1404,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1403
1404
  constructor(rootPath: string);
1404
1405
  readonly kind: "directory";
1405
1406
  get name(): string;
1407
+ get fullPath(): string;
1406
1408
  entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
1407
1409
  removeEntry(key: string, options?: {
1408
1410
  recursive?: boolean;
@@ -1850,6 +1852,7 @@ declare module "sliftutils/storage/remoteFileStorage" {
1850
1852
  export type RemoteOptions = {
1851
1853
  chunkBytes?: number;
1852
1854
  cacheBytes?: number;
1855
+ maxFetchBytes?: number;
1853
1856
  latencyMs?: number;
1854
1857
  stats?: {
1855
1858
  requestCount: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.12",
3
+ "version": "1.4.13",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -50,6 +50,7 @@ export type FileWrapper = {
50
50
  export type DirectoryWrapper = {
51
51
  readonly kind: "directory";
52
52
  readonly name: string;
53
+ readonly fullPath?: string;
53
54
  removeEntry(key: string, options?: {
54
55
  recursive?: boolean;
55
56
  }): Promise<void>;
@@ -89,6 +90,7 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
89
90
  constructor(rootPath: string);
90
91
  readonly kind: "directory";
91
92
  get name(): string;
93
+ get fullPath(): string;
92
94
  entries(): AsyncIterableIterator<[string, FileWrapper | DirectoryWrapper]>;
93
95
  removeEntry(key: string, options?: {
94
96
  recursive?: boolean;
@@ -60,6 +60,9 @@ export type FileWrapper = {
60
60
  export type DirectoryWrapper = {
61
61
  readonly kind: "directory";
62
62
  readonly name: string;
63
+ // Full path from the storage root, for diagnostics/logging (the native handle doesn't expose paths,
64
+ // so it's optional). e.g. "bulkDatabases2/myCollection".
65
+ readonly fullPath?: string;
63
66
  removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
64
67
  getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
65
68
  getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
@@ -291,6 +294,7 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
291
294
  }
292
295
  readonly kind = "directory" as const;
293
296
  get name() { return path.basename(this.rootPath); }
297
+ get fullPath() { return this.rootPath; }
294
298
  entries() { return this[Symbol.asyncIterator](); }
295
299
 
296
300
  async removeEntry(key: string, options?: { recursive?: boolean }) {
@@ -574,9 +578,11 @@ async function readWithRetry<T>(label: string, key: string, read: () => Promise<
574
578
  }
575
579
 
576
580
  function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
581
+ // Log the full path (root + filename) when available, so a failing file is identifiable.
582
+ const pathOf = (key: string) => handle.fullPath ? handle.fullPath + "/" + key : key;
577
583
  return {
578
584
  async getInfo(key: string) {
579
- return readWithRetry("getInfo", key, async () => {
585
+ return readWithRetry("getInfo", pathOf(key), async () => {
580
586
  const file = await handle.getFileHandle(key);
581
587
  const fileContent = await file.getFile();
582
588
  return {
@@ -586,7 +592,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
586
592
  });
587
593
  },
588
594
  async get(key: string): Promise<Buffer | undefined> {
589
- return readWithRetry("get", key, async () => {
595
+ return readWithRetry("get", pathOf(key), async () => {
590
596
  const file = await handle.getFileHandle(key);
591
597
  const fileContent = await file.getFile();
592
598
  const arrayBuffer = await fileContent.arrayBuffer();
@@ -600,7 +606,7 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
600
606
  },
601
607
 
602
608
  async getRange(key: string, config: { start: number; end: number }): Promise<Buffer | undefined> {
603
- return readWithRetry("getRange", key, async () => {
609
+ return readWithRetry("getRange", pathOf(key), async () => {
604
610
  const file = await handle.getFileHandle(key);
605
611
  const fileContent = await file.getFile();
606
612
  const clampedStart = Math.min(Math.max(config.start, 0), fileContent.size);
@@ -7,6 +7,7 @@ import os from "os";
7
7
  import { execFileSync } from "child_process";
8
8
  import { getExternalIP } from "socket-function/src/networking";
9
9
  import { forwardPort } from "socket-function/src/forwardPort";
10
+ import { WebSocketServer, WebSocket } from "ws";
10
11
 
11
12
  // Remote file server for sliftutils getRemoteFileStorage / BulkDatabase2. Serves one folder on disk over
12
13
  // self-signed HTTPS, authenticated with an auto-generated 6-word password (sent as
@@ -400,6 +401,85 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
400
401
  }
401
402
  });
402
403
 
404
+ // WebSocket transport: same operations as the HTTP handler, but binary-framed and multiplexed over one
405
+ // socket so large concurrent reads don't pay per-request HTTP overhead. Frame: [u32 headerLen LE][header
406
+ // JSON][body bytes]. Request header {id, op, path, start?, end?, password?}; response {id, status, error?}.
407
+ const EMPTY = Buffer.alloc(0);
408
+ const wss = new WebSocketServer({ server });
409
+ wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
410
+ const ip = clientIp(req);
411
+ let authed = false;
412
+ ws.on("message", (data: Buffer) => {
413
+ let id = 0;
414
+ const reply = (status: number, extra: object, body?: Buffer) => {
415
+ const h = Buffer.from(JSON.stringify(Object.assign({ id, status }, extra)), "utf8");
416
+ const len = Buffer.alloc(4);
417
+ len.writeUInt32LE(h.length, 0);
418
+ ws.send(Buffer.concat([len, h, body || EMPTY]));
419
+ };
420
+ void (async () => {
421
+ let header: any;
422
+ let body: Buffer;
423
+ try {
424
+ const headerLen = data.readUInt32LE(0);
425
+ header = JSON.parse(data.subarray(4, 4 + headerLen).toString("utf8"));
426
+ body = data.subarray(4 + headerLen);
427
+ } catch { return; }
428
+ id = header.id || 0;
429
+ try {
430
+ if (header.op === "auth") {
431
+ authed = timingSafeEqualStr(normalizePassword(header.password || ""), normPassword);
432
+ return reply(authed ? 200 : 401, {});
433
+ }
434
+ if (!authed) return reply(401, { error: "unauthorized" });
435
+ const rel = String(header.path || "");
436
+ const full = safeResolve(root, rel);
437
+ if (full === undefined) return reply(403, { error: "path escapes root" });
438
+ if (header.op === "info") {
439
+ let st: fs.Stats;
440
+ try { st = fs.statSync(full); } catch { return reply(404, {}); }
441
+ return reply(200, {}, Buffer.from(JSON.stringify({ size: st.size, lastModified: st.mtimeMs, dir: st.isDirectory() })));
442
+ }
443
+ if (header.op === "list") {
444
+ let entries: fs.Dirent[];
445
+ try { entries = fs.readdirSync(full, { withFileTypes: true }); }
446
+ catch (e) { return (e as NodeJS.ErrnoException).code === "ENOENT" ? reply(200, {}, Buffer.from("[]")) : reply(500, { error: (e as Error).message }); }
447
+ return reply(200, {}, Buffer.from(JSON.stringify(entries.filter(d => d.isFile() || d.isDirectory()).map(d => ({ name: d.name, dir: d.isDirectory() })))));
448
+ }
449
+ if (header.op === "read") {
450
+ let st: fs.Stats;
451
+ try { st = fs.statSync(full); } catch { return reply(404, {}); }
452
+ const start = Math.min(Math.max(Number(header.start) || 0, 0), st.size);
453
+ const end = header.end != null ? Math.min(Math.max(Number(header.end), start), st.size) : st.size;
454
+ recordAccess(ip, "read", rel, end - start);
455
+ if (end === start) return reply(200, {}, EMPTY);
456
+ const fh = await fs.promises.open(full, "r");
457
+ try {
458
+ const buf = Buffer.allocUnsafe(end - start);
459
+ const { bytesRead } = await fh.read(buf, 0, end - start, start);
460
+ reply(200, {}, bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
461
+ } finally { await fh.close(); }
462
+ return;
463
+ }
464
+ if (header.op === "append" || header.op === "set") {
465
+ fs.mkdirSync(path.dirname(full), { recursive: true });
466
+ if (header.op === "append") fs.appendFileSync(full, body);
467
+ else fs.writeFileSync(full, body);
468
+ recordAccess(ip, "write", rel, body.length);
469
+ return reply(200, {});
470
+ }
471
+ if (header.op === "remove") {
472
+ try { fs.rmSync(full, { recursive: true, force: true }); } catch (e) { return reply(500, { error: (e as Error).message }); }
473
+ return reply(200, {});
474
+ }
475
+ return reply(404, { error: "unknown op" });
476
+ } catch (e) {
477
+ try { reply(500, { error: String((e as Error)?.message || e) }); } catch { /* socket gone */ }
478
+ }
479
+ })();
480
+ });
481
+ });
482
+
403
483
  return new Promise<RemoteFileServerHandle>((resolve, reject) => {
404
484
  server.on("error", reject);
405
485
  server.listen(port, host, () => {
@@ -409,7 +489,7 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
409
489
  port: actualPort,
410
490
  password,
411
491
  url: `https://localhost:${actualPort}`,
412
- close: () => new Promise<void>(r => { if (flushTimer) clearInterval(flushTimer); server.close(() => r()); }),
492
+ close: () => new Promise<void>(r => { if (flushTimer) clearInterval(flushTimer); for (const c of wss.clients) c.terminate(); wss.close(); server.close(() => r()); }),
413
493
  });
414
494
  });
415
495
  });
@@ -2,6 +2,7 @@ import type { DirectoryWrapper } from "./FileFolderAPI";
2
2
  export type RemoteOptions = {
3
3
  chunkBytes?: number;
4
4
  cacheBytes?: number;
5
+ maxFetchBytes?: number;
5
6
  latencyMs?: number;
6
7
  stats?: {
7
8
  requestCount: number;
@@ -1,5 +1,4 @@
1
1
  import { isNode } from "typesafecss";
2
- import https from "https";
3
2
  import type { DirectoryWrapper, FileWrapper } from "./FileFolderAPI";
4
3
 
5
4
  // A remote server (remoteFileServer.js) exposed as a DirectoryWrapper — the SAME interface as the
@@ -17,6 +16,7 @@ const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024;
17
16
  export type RemoteOptions = {
18
17
  chunkBytes?: number;
19
18
  cacheBytes?: number;
19
+ maxFetchBytes?: number; // cap per read request; large reads split into this many bytes each
20
20
  latencyMs?: number; // artificial per-request delay, for simulating network latency in tests
21
21
  stats?: { requestCount: number; bytesFetched: number };
22
22
  };
@@ -35,46 +35,185 @@ type Stat = { size: number; lastModified: number; dir: boolean };
35
35
  // file, not one per block. Short enough that an append by another writer shows up promptly; our own
36
36
  // writes invalidate it immediately.
37
37
  const INFO_TTL_MS = 2000;
38
+ // We keep at most this many bytes of read/write requests in flight at once (the rest queue), so a big
39
+ // read doesn't fire hundreds of MB of requests at the server simultaneously.
40
+ const MAX_INFLIGHT_BYTES = 64 * 1024 * 1024;
41
+ // A single read request fetches at most this much; large reads are split into this many concurrent
42
+ // requests (bounded by MAX_INFLIGHT_BYTES).
43
+ const DEFAULT_MAX_FETCH_BYTES = 4 * 1024 * 1024;
44
+ const STATS_LOG_INTERVAL_MS = 10 * 1000;
45
+ const RECV_WINDOW_MS = 60 * 1000;
38
46
 
47
+ function fmtBytes(n: number): string {
48
+ if (n < 1024) return n + "B";
49
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
50
+ if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + "MB";
51
+ return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
52
+ }
53
+
54
+ // Minimal WebSocket surface common to the browser's WebSocket and the `ws` package.
55
+ type WSLike = {
56
+ readyState: number;
57
+ binaryType: string;
58
+ send(data: Uint8Array): void;
59
+ close(): void;
60
+ onopen: (() => void) | null;
61
+ onmessage: ((ev: { data: ArrayBuffer | Buffer }) => void) | null;
62
+ onclose: (() => void) | null;
63
+ onerror: ((e: unknown) => void) | null;
64
+ };
65
+ function makeWebSocket(url: string): WSLike {
66
+ if (isNode()) {
67
+ // `ws` is a Node dep; the eval hides it from the browser bundler (this branch never runs there).
68
+ const WS = (eval("require") as NodeRequire)("ws");
69
+ return new WS(url, { rejectUnauthorized: false }) as WSLike; // self-signed cert
70
+ }
71
+ return new WebSocket(url) as unknown as WSLike;
72
+ }
73
+
74
+ // Binary frame: [u32 headerLen LE][header JSON][body bytes].
75
+ function encodeFrame(header: object, body?: Buffer): Buffer {
76
+ const h = Buffer.from(JSON.stringify(header), "utf8");
77
+ const len = Buffer.alloc(4);
78
+ len.writeUInt32LE(h.length, 0);
79
+ return Buffer.concat([len, h, body || EMPTY]);
80
+ }
81
+ function decodeFrame(buf: Buffer): { header: any; body: Buffer } {
82
+ const headerLen = buf.readUInt32LE(0);
83
+ const header = JSON.parse(buf.subarray(4, 4 + headerLen).toString("utf8"));
84
+ return { header, body: buf.subarray(4 + headerLen) };
85
+ }
86
+
87
+ type QueuedRequest = { id: number; frame: Buffer; bytes: number; resolve: (r: { status: number; body: Buffer }) => void; reject: (e: Error) => void };
88
+
89
+ // One WebSocket to a remote server, multiplexing all ops over it. Requests are queued and only sent
90
+ // while < MAX_INFLIGHT_BYTES are outstanding; every 10s (when there's traffic) it logs the queue depth
91
+ // and throughput.
39
92
  class Connection {
40
- private agent = isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined;
41
93
  private cache: RangeCache;
42
94
  private infoCache = new Map<string, { stat: Stat | undefined; at: number }>();
95
+
96
+ private ws: WSLike | undefined;
97
+ private connecting: Promise<void> | undefined;
98
+ private nextId = 1;
99
+ private pending = new Map<number, { resolve: (r: { status: number; body: Buffer }) => void; reject: (e: Error) => void; bytes: number }>();
100
+ private queue: QueuedRequest[] = [];
101
+ private inFlightBytes = 0;
102
+
103
+ private receivedTotal = 0;
104
+ private recvWindow: { t: number; n: number }[] = [];
105
+ private wsUrl: string;
106
+ private host: string;
107
+ private statsTimer: ReturnType<typeof setInterval>;
108
+
43
109
  constructor(public url: string, public password: string, private opts: RemoteOptions) {
44
- this.cache = new RangeCache(opts.chunkBytes || DEFAULT_CHUNK_BYTES, opts.cacheBytes || DEFAULT_CACHE_BYTES);
45
110
  this.url = url.replace(/\/+$/, "");
111
+ this.wsUrl = this.url.replace(/^http/, "ws"); // https→wss, http→ws
112
+ this.host = (() => { try { return new URL(this.url).host; } catch { return this.url; } })();
113
+ this.cache = new RangeCache(opts.chunkBytes || DEFAULT_CHUNK_BYTES, opts.cacheBytes || DEFAULT_CACHE_BYTES, opts.maxFetchBytes || DEFAULT_MAX_FETCH_BYTES);
114
+ this.statsTimer = setInterval(() => this.logStats(), STATS_LOG_INTERVAL_MS);
115
+ (this.statsTimer as { unref?: () => void }).unref?.();
46
116
  }
47
117
 
48
- // 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();
68
- });
118
+ close() {
119
+ clearInterval(this.statsTimer);
120
+ try { this.ws?.close(); } catch { /* */ }
121
+ this.ws = undefined;
122
+ this.connecting = undefined;
123
+ }
124
+
125
+ private logStats() {
126
+ const now = Date.now();
127
+ this.recvWindow = this.recvWindow.filter(e => now - e.t < RECV_WINDOW_MS);
128
+ if (this.inFlightBytes === 0 && this.queue.length === 0) return; // only when there's traffic
129
+ const recv60 = this.recvWindow.reduce((a, e) => a + e.n, 0);
130
+ const queuedBytes = this.queue.reduce((a, q) => a + q.bytes, 0);
131
+ console.log(`[remote ${this.host}] outstanding ${fmtBytes(this.inFlightBytes)} (${this.pending.size} req), queued ${fmtBytes(queuedBytes)} (${this.queue.length} req), recv/60s ${fmtBytes(recv60)}, recv total ${fmtBytes(this.receivedTotal)}`);
132
+ }
133
+
134
+ private ensureConnected(): Promise<void> {
135
+ if (this.ws && this.ws.readyState === 1) return Promise.resolve();
136
+ if (this.connecting) return this.connecting;
137
+ this.connecting = new Promise<void>((resolve, reject) => {
138
+ let authed = false;
139
+ const ws = makeWebSocket(this.wsUrl);
140
+ ws.binaryType = "arraybuffer";
141
+ this.ws = ws;
142
+ ws.onopen = () => ws.send(encodeFrame({ id: 0, op: "auth", password: this.password }));
143
+ ws.onmessage = (ev) => {
144
+ const buf = Buffer.isBuffer(ev.data) ? ev.data as Buffer : Buffer.from(ev.data as ArrayBuffer);
145
+ const { header } = decodeFrame(buf);
146
+ if (!authed && header.id === 0) {
147
+ if (header.status === 200) { authed = true; resolve(); }
148
+ else { reject(new Error("remote: authentication failed")); try { ws.close(); } catch { /* */ } }
149
+ return;
150
+ }
151
+ this.onMessage(buf);
152
+ };
153
+ ws.onerror = () => { if (!authed) reject(new Error("remote: websocket connection error")); };
154
+ ws.onclose = () => {
155
+ if (!authed) reject(new Error("remote: connection closed before authenticating"));
156
+ this.handleDisconnect();
157
+ };
158
+ });
159
+ // Let a failed connection be retried next time.
160
+ this.connecting.catch(() => { this.ws = undefined; this.connecting = undefined; });
161
+ return this.connecting;
162
+ }
163
+
164
+ private onMessage(buf: Buffer) {
165
+ const { header, body } = decodeFrame(buf);
166
+ const p = this.pending.get(header.id);
167
+ if (!p) return;
168
+ this.pending.delete(header.id);
169
+ this.inFlightBytes -= p.bytes;
170
+ this.receivedTotal += body.length;
171
+ if (this.opts.stats) this.opts.stats.bytesFetched += body.length;
172
+ this.recvWindow.push({ t: Date.now(), n: body.length });
173
+ p.resolve({ status: header.status, body });
174
+ this.drain();
175
+ }
176
+
177
+ private handleDisconnect() {
178
+ const err = new Error("remote: websocket disconnected");
179
+ for (const p of this.pending.values()) p.reject(err);
180
+ for (const q of this.queue) q.reject(err);
181
+ this.pending.clear();
182
+ this.queue = [];
183
+ this.inFlightBytes = 0;
184
+ this.ws = undefined;
185
+ this.connecting = undefined;
186
+ }
187
+
188
+ private drain() {
189
+ while (this.queue.length && this.ws && this.ws.readyState === 1) {
190
+ const next = this.queue[0];
191
+ if (this.inFlightBytes > 0 && this.inFlightBytes + next.bytes > MAX_INFLIGHT_BYTES) break; // hold the queue
192
+ this.queue.shift();
193
+ this.inFlightBytes += next.bytes;
194
+ this.pending.set(next.id, { resolve: next.resolve, reject: next.reject, bytes: next.bytes });
195
+ if (this.opts.stats) this.opts.stats.requestCount++;
196
+ this.ws.send(next.frame);
69
197
  }
70
- const res = await fetch(fullUrl, { method, headers, body: body ? new Uint8Array(body) : undefined });
71
- return { status: res.status, body: Buffer.from(await res.arrayBuffer()) };
198
+ }
199
+
200
+ // Sends one request over the WebSocket (queued + throttled). `bytes` is the expected payload size,
201
+ // used for the in-flight cap.
202
+ private async request(op: string, params: Record<string, unknown>, body: Buffer | undefined, bytes: number): Promise<{ status: number; body: Buffer }> {
203
+ if (this.opts.latencyMs) await sleep(this.opts.latencyMs);
204
+ await this.ensureConnected();
205
+ const id = this.nextId++;
206
+ const frame = encodeFrame({ id, op, ...params }, body);
207
+ return new Promise((resolve, reject) => {
208
+ this.queue.push({ id, frame, bytes: Math.max(bytes, body ? body.length : 0, 1), resolve, reject });
209
+ this.drain();
210
+ });
72
211
  }
73
212
 
74
213
  async stat(path: string): Promise<Stat | undefined> {
75
214
  const cached = this.infoCache.get(path);
76
215
  if (cached && Date.now() - cached.at < INFO_TTL_MS) return cached.stat;
77
- const r = await this.request("GET", "/info", { path });
216
+ const r = await this.request("info", { path }, undefined, 256);
78
217
  let stat: Stat | undefined;
79
218
  if (r.status === 404) stat = undefined;
80
219
  else if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
@@ -83,7 +222,7 @@ class Connection {
83
222
  return stat;
84
223
  }
85
224
  async list(path: string): Promise<{ name: string; dir: boolean }[]> {
86
- const r = await this.request("GET", "/list", { path });
225
+ const r = await this.request("list", { path }, undefined, 4096);
87
226
  if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
88
227
  return JSON.parse(r.body.toString("utf8"));
89
228
  }
@@ -91,26 +230,24 @@ class Connection {
91
230
  return (await this.cache.read(this, path, start, end)) ?? EMPTY;
92
231
  }
93
232
  async readServer(path: string, start: number, end: number): Promise<Buffer | undefined> {
94
- const r = await this.request("GET", "/read", { path, start: String(start), end: String(end) });
233
+ const r = await this.request("read", { path, start, end }, undefined, end - start);
95
234
  if (r.status === 404) return undefined;
96
235
  if (r.status !== 200) throw new Error(`remote read failed (${r.status})`);
97
- if (this.opts.stats) this.opts.stats.bytesFetched += r.body.length;
98
236
  return r.body;
99
237
  }
100
238
  async append(path: string, body: Buffer): Promise<void> {
101
- const r = await this.request("PUT", "/append", { path }, body);
239
+ const r = await this.request("append", { path }, body, body.length);
102
240
  if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
103
- // Append-only keeps existing bytes, so cached chunks stay valid; just the size changed.
104
- this.infoCache.delete(path);
241
+ this.infoCache.delete(path); // append-only keeps existing bytes; only the size changed
105
242
  }
106
243
  async set(path: string, body: Buffer): Promise<void> {
107
- const r = await this.request("PUT", "/set", { path }, body);
244
+ const r = await this.request("set", { path }, body, body.length);
108
245
  if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
109
246
  this.cache.invalidate(path);
110
247
  this.infoCache.delete(path);
111
248
  }
112
249
  async remove(path: string): Promise<void> {
113
- const r = await this.request("DELETE", "/remove", { path });
250
+ const r = await this.request("remove", { path }, undefined, 256);
114
251
  if (r.status !== 200) throw new Error(`remote remove failed (${r.status})`);
115
252
  this.cache.invalidate(path);
116
253
  this.infoCache.delete(path);
@@ -123,7 +260,7 @@ class Connection {
123
260
  class RangeCache {
124
261
  private chunks = new Map<string, Buffer>(); // path + "" + chunkIndex, insertion-ordered (LRU)
125
262
  private bytes = 0;
126
- constructor(private chunkBytes: number, private budget: number) { }
263
+ constructor(private chunkBytes: number, private budget: number, private maxFetchBytes: number) { }
127
264
  private key(path: string, c: number) { return path + "" + c; }
128
265
  private peek(path: string, c: number): Buffer | undefined {
129
266
  const k = this.key(path, c);
@@ -152,24 +289,37 @@ class RangeCache {
152
289
  async read(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
153
290
  if (end <= start) return EMPTY;
154
291
  const CHUNK = this.chunkBytes;
292
+ const maxRunChunks = Math.max(1, Math.floor(this.maxFetchBytes / CHUNK));
155
293
  const firstChunk = Math.floor(start / CHUNK);
156
294
  const lastChunk = Math.floor((end - 1) / CHUNK);
157
- let fetchFrom = -1, fetchTo = -1;
295
+ // Collect the chunks we don't have deeply enough into bounded contiguous runs (each <= maxFetchBytes).
296
+ const runs: { from: number; to: number }[] = [];
297
+ let runFrom = -1;
158
298
  for (let c = firstChunk; c <= lastChunk; c++) {
159
299
  const cStart = c * CHUNK;
160
300
  const needEnd = Math.min(end, cStart + CHUNK) - cStart;
161
301
  const have = this.peek(path, c);
162
- if (!have || have.length < needEnd) { if (fetchFrom < 0) fetchFrom = c; fetchTo = c; }
302
+ if (!have || have.length < needEnd) {
303
+ if (runFrom < 0) runFrom = c;
304
+ if (c - runFrom + 1 >= maxRunChunks) { runs.push({ from: runFrom, to: c }); runFrom = -1; }
305
+ } else if (runFrom >= 0) {
306
+ runs.push({ from: runFrom, to: c - 1 });
307
+ runFrom = -1;
308
+ }
163
309
  }
164
- if (fetchFrom >= 0) {
165
- const bytes = await conn.readServer(path, fetchFrom * CHUNK, (fetchTo + 1) * CHUNK);
166
- if (bytes === undefined) return undefined;
167
- for (let c = fetchFrom; c <= fetchTo; c++) {
168
- const off = (c - fetchFrom) * CHUNK;
310
+ if (runFrom >= 0) runs.push({ from: runFrom, to: lastChunk });
311
+ // Fetch all runs concurrently; the connection's queue caps total in-flight bytes.
312
+ let missingFile = false;
313
+ await Promise.all(runs.map(async run => {
314
+ const bytes = await conn.readServer(path, run.from * CHUNK, (run.to + 1) * CHUNK);
315
+ if (bytes === undefined) { missingFile = true; return; }
316
+ for (let c = run.from; c <= run.to; c++) {
317
+ const off = (c - run.from) * CHUNK;
169
318
  if (off >= bytes.length) break;
170
319
  this.store(path, c, bytes.subarray(off, Math.min(off + CHUNK, bytes.length)));
171
320
  }
172
- }
321
+ }));
322
+ if (missingFile) return undefined;
173
323
  const parts: Buffer[] = [];
174
324
  for (let c = firstChunk; c <= lastChunk; c++) {
175
325
  const cStart = c * CHUNK;
@@ -228,6 +378,7 @@ class RemoteDirectoryWrapper implements DirectoryWrapper {
228
378
  // native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
229
379
  readonly kind = "directory" as const;
230
380
  get name() { return baseName(this.dirPath); }
381
+ get fullPath() { return this.dirPath; }
231
382
  async removeEntry(key: string): Promise<void> {
232
383
  await this.conn.remove(joinPath(this.dirPath, key));
233
384
  }
@@ -271,17 +422,19 @@ export function getRemoteDirectoryHandle(url: string, password: string, options:
271
422
 
272
423
  export type RemoteConnectResult = { status: "ok" } | { status: "unauthorized" } | { status: "unreachable"; error: string };
273
424
 
274
- // Verifies a server is reachable and the password works — by 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).
425
+ // Verifies a server is reachable and the password works — by opening the WebSocket, authenticating, and
426
+ // listing the root. Distinguishes "connected" / "wrong password" / "couldn't reach it" (the last usually
427
+ // meaning the self-signed cert isn't trusted yet in the browser, since the socket just fails to open).
277
428
  export async function testRemoteConnection(url: string, password: string, options: RemoteOptions = {}): Promise<RemoteConnectResult> {
278
429
  const conn = new Connection(url, password, options);
279
430
  try {
280
- 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}` };
431
+ await conn.list("");
432
+ return { status: "ok" };
284
433
  } catch (e) {
285
- return { status: "unreachable", error: (e as Error)?.message || String(e) };
434
+ const msg = (e as Error)?.message || String(e);
435
+ if (/auth/i.test(msg)) return { status: "unauthorized" };
436
+ return { status: "unreachable", error: msg };
437
+ } finally {
438
+ conn.close();
286
439
  }
287
440
  }