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
|
@@ -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
|
-
|
|
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[] = [];
|