sliftutils 1.4.18 → 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
@@ -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.18",
3
+ "version": "1.4.19",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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[] = [];