sliftutils 1.4.17 → 1.4.19

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
@@ -921,7 +921,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
921
921
  export declare const noopReactiveDeps: ReactiveDeps;
922
922
  export type StorageFactory = (path: string) => Promise<FileStorage>;
923
923
  export type BulkDatabase2Config = {
924
- triggerThrottleMs?: number;
924
+ maxTriggerThrottleMs?: number;
925
925
  };
926
926
  export declare class BulkDatabaseBase<T extends {
927
927
  key: string;
@@ -1396,6 +1396,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1396
1396
  write(value: Buffer): Promise<void>;
1397
1397
  close(): Promise<void>;
1398
1398
  }>;
1399
+ getURL?(): Promise<string>;
1399
1400
  };
1400
1401
  export type DirectoryWrapper = {
1401
1402
  readonly kind: "directory";
@@ -1428,6 +1429,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1428
1429
  arrayBuffer: () => Promise<ArrayBuffer>;
1429
1430
  };
1430
1431
  }>;
1432
+ getURL(): Promise<string>;
1431
1433
  createWritable(config?: {
1432
1434
  keepExistingData?: boolean;
1433
1435
  }): Promise<{
@@ -1492,6 +1494,8 @@ declare module "sliftutils/storage/FileFolderAPI" {
1492
1494
  isRemote?: boolean;
1493
1495
  };
1494
1496
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
1497
+ export declare function getFileURL(file: FileWrapper): Promise<string>;
1498
+ export declare function disposeFileURL(url: string): void;
1495
1499
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
1496
1500
  export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
1497
1501
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.17",
3
+ "version": "1.4.19",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -17,7 +17,7 @@ export interface ReactiveDeps {
17
17
  export declare const noopReactiveDeps: ReactiveDeps;
18
18
  export type StorageFactory = (path: string) => Promise<FileStorage>;
19
19
  export type BulkDatabase2Config = {
20
- triggerThrottleMs?: number;
20
+ maxTriggerThrottleMs?: number;
21
21
  };
22
22
  export declare class BulkDatabaseBase<T extends {
23
23
  key: string;
@@ -131,18 +131,18 @@ export type StorageFactory = (path: string) => Promise<FileStorage>;
131
131
 
132
132
  // Optional per-collection configuration.
133
133
  export type BulkDatabase2Config = {
134
- // When set (> 0), the reactive change notifications writes/loads emit are throttled and BATCHED
135
- // globally (across all keys), so a high-frequency write source doesn't re-run watchers on every single
136
- // change. The throttle RAMPS like the write-flush one: the first change after a lull notifies
137
- // immediately, then under sustained changes the delay doubles up to triggerThrottleMs, coalescing the
138
- // burst into one notification. In-memory/async reads are always current — only the OBSERVABLE
139
- // notification (the mobx re-render trigger) is delayed and merged.
140
- triggerThrottleMs?: number;
134
+ // The MAXIMUM throttle (ms) for reactive change notifications; the actual delay RAMPS UP to it, it is
135
+ // never applied all at once. When set (> 0), the notifications writes/loads emit are batched globally
136
+ // (across all keys) so a high-frequency write source doesn't re-run watchers on every single change: a
137
+ // change after a lull notifies immediately, then under sustained changes the delay doubles up to this
138
+ // ceiling, coalescing the burst into one notification. In-memory/async reads are always current — only
139
+ // the OBSERVABLE notification (the mobx re-render trigger) is delayed and merged.
140
+ maxTriggerThrottleMs?: number;
141
141
  };
142
142
 
143
143
  // Trigger-throttle ramp: the first deferred notification waits this long, then the delay doubles on each
144
- // further change up to BulkDatabase2Config.triggerThrottleMs. ~16ms ≈ one animation frame, so an isolated
145
- // burst still notifies within a frame.
144
+ // further change up to BulkDatabase2Config.maxTriggerThrottleMs. ~16ms ≈ one animation frame, so an
145
+ // isolated burst still notifies within a frame.
146
146
  const TRIGGER_THROTTLE_FIRST_STEP_MS = 16;
147
147
 
148
148
  // The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
@@ -310,7 +310,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
310
310
  // the cache and may swap the reader) can never leave a stale entry behind.
311
311
  private dataGen = 0;
312
312
 
313
- // ---- trigger throttle (see BulkDatabase2Config.triggerThrottleMs) ----
313
+ // ---- trigger throttle (see BulkDatabase2Config.maxTriggerThrottleMs) ----
314
314
  // Signals whose notification is deferred, the pending flush timer, and the ramping delay. Data state is
315
315
  // already updated synchronously; only these observable notifications are batched/delayed.
316
316
  private pendingSignals = new Set<string>();
@@ -363,14 +363,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
363
363
  return this.readerKeys?.has(key) ?? false;
364
364
  }
365
365
 
366
- // Notify observers of `signal`. With triggerThrottleMs set, notifications are batched and delayed on a
367
- // ramping schedule so a high-frequency source can't re-run watchers on every change: a change after a
366
+ // Notify observers of `signal`. With maxTriggerThrottleMs set, notifications are batched and delayed on
367
+ // a ramping schedule so a high-frequency source can't re-run watchers on every change: a change after a
368
368
  // lull notifies on the next tick (no real delay, but all of one change's signals batch together);
369
369
  // under sustained changes the delay doubles up to the max, coalescing the burst into one notification.
370
370
  // Only the OBSERVABLE notification is delayed — the underlying data was already updated, so a read in
371
371
  // the meantime still sees current values.
372
372
  private invalidateSignal(signal: string) {
373
- const maxMs = this.config.triggerThrottleMs ?? 0;
373
+ const maxMs = this.config.maxTriggerThrottleMs ?? 0;
374
374
  if (maxMs <= 0) { this.deps.invalidate(signal); return; }
375
375
  this.pendingSignals.add(signal);
376
376
  const now = Date.now();
@@ -46,6 +46,7 @@ export type FileWrapper = {
46
46
  write(value: Buffer): Promise<void>;
47
47
  close(): Promise<void>;
48
48
  }>;
49
+ getURL?(): Promise<string>;
49
50
  };
50
51
  export type DirectoryWrapper = {
51
52
  readonly kind: "directory";
@@ -78,6 +79,7 @@ export declare class NodeJSFileHandleWrapper implements FileWrapper {
78
79
  arrayBuffer: () => Promise<ArrayBuffer>;
79
80
  };
80
81
  }>;
82
+ getURL(): Promise<string>;
81
83
  createWritable(config?: {
82
84
  keepExistingData?: boolean;
83
85
  }): Promise<{
@@ -142,5 +144,7 @@ export type FileStorage = IStorageRaw & {
142
144
  isRemote?: boolean;
143
145
  };
144
146
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
147
+ export declare function getFileURL(file: FileWrapper): Promise<string>;
148
+ export declare function disposeFileURL(url: string): void;
145
149
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
146
150
  export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
@@ -56,6 +56,10 @@ export type FileWrapper = {
56
56
  write(value: Buffer): Promise<void>;
57
57
  close(): Promise<void>;
58
58
  }>;
59
+ // Returns a URL for the file's bytes, usable in <video>/<img>/fetch. Optional — the native browser
60
+ // FileSystemFileHandle has no such method, so prefer the getFileURL() helper, which falls back to a
61
+ // blob: URL via createObjectURL for native handles. Always release it with disposeFileURL when done.
62
+ getURL?(): Promise<string>;
59
63
  };
60
64
  export type DirectoryWrapper = {
61
65
  readonly kind: "directory";
@@ -261,6 +265,10 @@ export class NodeJSFileHandleWrapper implements FileWrapper {
261
265
  };
262
266
  }
263
267
 
268
+ async getURL() {
269
+ return "file://" + path.resolve(this.filePath);
270
+ }
271
+
264
272
  async createWritable(config?: { keepExistingData?: boolean }) {
265
273
  let fileHandle: fs.promises.FileHandle;
266
274
  const flags = config?.keepExistingData ? "r+" : "w";
@@ -715,6 +723,25 @@ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
715
723
  };
716
724
  }
717
725
 
726
+ // Returns a URL for a file's bytes, ready to drop into <video>/<img>/fetch. A native (local) file becomes
727
+ // an in-memory blob: URL; a remote file becomes an https URL into the server's range-capable /media
728
+ // endpoint (auth token in the query, since a media element can't send headers). Both support HTTP range /
729
+ // seeking. ALWAYS hand the result to disposeFileURL when finished — for blob: URLs that frees memory.
730
+ export async function getFileURL(file: FileWrapper): Promise<string> {
731
+ if (file.getURL) return file.getURL();
732
+ // Native FileSystemFileHandle (or any Blob-backed file): a blob: URL over the File itself.
733
+ const f = await file.getFile();
734
+ return URL.createObjectURL(f as unknown as Blob);
735
+ }
736
+
737
+ // Releases a URL from getFileURL. blob: URLs leak until the document is gone unless revoked; https/file:
738
+ // URLs need no cleanup, so this is a no-op for them.
739
+ export function disposeFileURL(url: string): void {
740
+ if (url.startsWith("blob:")) {
741
+ try { URL.revokeObjectURL(url); } catch { /* not in a browser, or already revoked */ }
742
+ }
743
+ }
744
+
718
745
  // A StorageFactory backed by a remote server (path -> FileStorage), for code that injects its own
719
746
  // storage into BulkDatabase2 rather than going through the directory prompt.
720
747
  export function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage> {
@@ -274,6 +274,40 @@ function formatBytes(n: number): string {
274
274
  return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
275
275
  }
276
276
 
277
+ // Parse a single-range HTTP Range header ("bytes=start-end", "bytes=start-", "bytes=-suffix") into an
278
+ // INCLUSIVE [start, end]. Returns "unsatisfiable" for a range past the file, or undefined for no/garbled
279
+ // range (caller then serves the whole file). Only single ranges are supported (what media players use).
280
+ function parseRange(header: string, size: number): { start: number; end: number } | "unsatisfiable" | undefined {
281
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
282
+ if (!m) return undefined;
283
+ const sRaw = m[1], eRaw = m[2];
284
+ if (sRaw === "" && eRaw === "") return undefined;
285
+ let start: number, end: number;
286
+ if (sRaw === "") {
287
+ const suffix = parseInt(eRaw, 10);
288
+ if (!suffix) return "unsatisfiable";
289
+ start = Math.max(0, size - suffix);
290
+ end = size - 1;
291
+ } else {
292
+ start = parseInt(sRaw, 10);
293
+ end = eRaw === "" ? size - 1 : parseInt(eRaw, 10);
294
+ }
295
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return "unsatisfiable";
296
+ return { start, end: Math.min(end, size - 1) };
297
+ }
298
+
299
+ const MEDIA_TYPES: Record<string, string> = {
300
+ mp4: "video/mp4", m4v: "video/mp4", webm: "video/webm", mkv: "video/x-matroska", mov: "video/quicktime",
301
+ avi: "video/x-msvideo", ogv: "video/ogg", ts: "video/mp2t",
302
+ mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", wav: "audio/wav", ogg: "audio/ogg", opus: "audio/opus",
303
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml",
304
+ pdf: "application/pdf", txt: "text/plain; charset=utf-8", json: "application/json",
305
+ };
306
+ function mediaContentType(filePath: string): string {
307
+ const ext = filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase();
308
+ return MEDIA_TYPES[ext] || "application/octet-stream";
309
+ }
310
+
277
311
  // Public (no-auth) landing page. The browser can't trust a self-signed cert from a background fetch, so
278
312
  // the user opens this URL once, accepts the browser's security warning, and the cert becomes trusted for
279
313
  // the origin — then the app's fetches work. This page is what they see after accepting.
@@ -336,8 +370,9 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
336
370
  const origin = (req.headers["origin"] as string) || "*";
337
371
  const cors: Record<string, string> = {
338
372
  "Access-Control-Allow-Origin": origin,
339
- "Access-Control-Allow-Methods": "GET, PUT, POST, DELETE, OPTIONS",
340
- "Access-Control-Allow-Headers": "authorization, content-type",
373
+ "Access-Control-Allow-Methods": "GET, HEAD, PUT, POST, DELETE, OPTIONS",
374
+ "Access-Control-Allow-Headers": "authorization, content-type, range",
375
+ "Access-Control-Expose-Headers": "content-range, accept-ranges, content-length",
341
376
  "Access-Control-Max-Age": "86400",
342
377
  "Vary": "Origin",
343
378
  };
@@ -359,7 +394,8 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
359
394
  }
360
395
 
361
396
  const auth = (req.headers["authorization"] as string) || "";
362
- const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
397
+ // Bearer header for API clients; ?token= query for media URLs (a <video> element can't set headers).
398
+ const token = (auth.startsWith("Bearer ") ? auth.slice(7) : "") || url.searchParams.get("token") || "";
363
399
  if (!timingSafeEqualStr(normalizePassword(token), normPassword)) return sendJson(401, { error: "unauthorized" });
364
400
 
365
401
  const relPath = url.searchParams.get("path") || "";
@@ -391,6 +427,33 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
391
427
  fs.createReadStream(full, { start, end: end - 1 }).on("error", () => res.end()).pipe(res);
392
428
  return;
393
429
  }
430
+ // Range-capable media endpoint for <video>/<img>/fetch: honors the Range header (206 +
431
+ // Content-Range + Accept-Ranges) so seeking works. Auth came from the ?token= query above.
432
+ if ((req.method === "GET" || req.method === "HEAD") && op === "/media") {
433
+ let st: fs.Stats;
434
+ try { st = fs.statSync(full); } catch { return sendJson(404, { error: "not found" }); }
435
+ if (st.isDirectory()) return sendJson(400, { error: "is a directory" });
436
+ const rangeHeader = req.headers["range"] as string | undefined;
437
+ const range = rangeHeader ? parseRange(rangeHeader, st.size) : undefined;
438
+ if (range === "unsatisfiable") {
439
+ res.writeHead(416, Object.assign({}, cors, { "Content-Range": `bytes */${st.size}`, "Accept-Ranges": "bytes" }));
440
+ return res.end();
441
+ }
442
+ const start = range ? range.start : 0;
443
+ const end = range ? range.end : st.size - 1; // inclusive
444
+ const length = st.size === 0 ? 0 : end - start + 1;
445
+ const headers: Record<string, string> = Object.assign({}, cors, {
446
+ "Content-Type": mediaContentType(full),
447
+ "Accept-Ranges": "bytes",
448
+ "Content-Length": String(length),
449
+ });
450
+ if (range) headers["Content-Range"] = `bytes ${start}-${end}/${st.size}`;
451
+ res.writeHead(range ? 206 : 200, headers);
452
+ if (req.method === "HEAD" || length === 0) return res.end();
453
+ recordAccess(clientIp(req), "read", relPath, length);
454
+ fs.createReadStream(full, { start, end }).on("error", () => res.end()).pipe(res);
455
+ return;
456
+ }
394
457
  if ((req.method === "PUT" || req.method === "POST") && (op === "/append" || op === "/set")) {
395
458
  const body = await readBody(req);
396
459
  fs.mkdirSync(path.dirname(full), { recursive: true });
@@ -358,6 +358,11 @@ class RemoteFileWrapper implements FileWrapper {
358
358
  },
359
359
  };
360
360
  }
361
+ async getURL() {
362
+ // HTTPS URL into the server's range-capable /media endpoint. The token rides in the query because a
363
+ // <video>/<img> element can't send an Authorization header.
364
+ return `${this.conn.url}/media?path=${encodeURIComponent(this.filePath)}&token=${encodeURIComponent(this.conn.password)}`;
365
+ }
361
366
  async createWritable(config?: { keepExistingData?: boolean }) {
362
367
  const conn = this.conn, filePath = this.filePath, append = !!config?.keepExistingData;
363
368
  const chunks: Buffer[] = [];