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 +5 -1
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +1 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +13 -13
- package/storage/FileFolderAPI.d.ts +4 -0
- package/storage/FileFolderAPI.tsx +27 -0
- package/storage/remoteFileServer.ts +66 -3
- package/storage/remoteFileStorage.ts +5 -0
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// immediately, then under sustained changes the delay doubles up to
|
|
138
|
-
// burst into one notification. In-memory/async reads are always current — only
|
|
139
|
-
// notification (the mobx re-render trigger) is delayed and merged.
|
|
140
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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[] = [];
|