merge: v2/1560-downloadfile (#1586)

This commit is contained in:
cliffhall 2026-07-01 22:04:18 -04:00
commit 52cd90bd50

View file

@ -2,22 +2,23 @@
* Browser-side helpers for triggering file downloads from in-memory content.
*
* Centralized so the temp-anchor incantation (`appendChild` for Firefox,
* try/finally to keep cleanup bulletproof, `revokeObjectURL` to release the
* object URL) lives in one place — and so the wiring is unit-testable
* under happy-dom without dragging React along.
* deferred `revokeObjectURL` so the scheduled download can read the blob)
* lives in one place — and so the wiring is unit-testable under happy-dom
* without dragging React along.
*/
/**
* Download an in-memory string as a JSON file. Uses a temporary anchor
* Download an in-memory {@link Blob} as `filename`. Uses a temporary anchor
* element to trigger the browser's save dialog. The append-to-body step is
* for older Firefox versions that wouldn't fire `click()` on a detached
* anchor; modern browsers don't require it but it stays as the safe path.
*
* The cleanup (removeChild + revokeObjectURL) runs in a `finally` so even
* a thrown `click()` doesn't leak the DOM node or the object URL.
* The object-URL revoke is deferred to a task: `link.click()` only schedules
* the download, and revoking the URL synchronously can abort it before the
* browser reads the blob (Firefox/Safari, intermittently Chrome for larger
* blobs).
*/
export function downloadJsonFile(filename: string, json: string): void {
const blob = new Blob([json], { type: "application/json" });
export function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
@ -27,7 +28,43 @@ export function downloadJsonFile(filename: string, json: string): void {
anchor.click();
} finally {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
}
/** Download an in-memory JSON string as `filename`. */
export function downloadJsonFile(filename: string, json: string): void {
downloadBlob(filename, new Blob([json], { type: "application/json" }));
}
/**
* Derive a safe suggested filename from a resource URI: the last path segment,
* stripped of control/format characters, path separators, and characters
* disallowed in filenames on common platforms. Falls back to `"download"` when
* nothing usable remains.
*/
export function fileNameFromUri(uri: string): string {
const tail = uri.split(/[\\/]/).pop() ?? "";
const safe = tail
.replace(/[\p{Cc}\p{Cf}]+/gu, "")
.replace(/[\\/:*?"<>|]+/g, "_")
.trim();
return safe.length > 0 ? safe.slice(0, 255) : "download";
}
/**
* Parse `url` and return it only if its scheme is `http:` or `https:`;
* otherwise null. Shared http(s)-only allowlist for opening or downloading
* server-supplied URLs.
*/
export function isHttpUrl(url: string): URL | null {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:"
? parsed
: null;
} catch {
return null;
}
}