Files
helper-tools/src/helpers/downloads.ts
T
2026-09-01 02:33:45 +02:00

92 lines
2.5 KiB
TypeScript

import { assertBoundedText, assertPositiveSafeInteger } from "./limits";
export interface ObjectUrlLease {
readonly url: string;
readonly revoked: boolean;
revoke(): void;
}
export function sanitizeDownloadFilename(
input: string,
fallback = "download.bin",
maximumLength = 180,
): string {
assertPositiveSafeInteger(maximumLength, "Maximum filename length");
const cleaned = cleanFilename(input);
const fallbackName = cleanFilename(fallback) || "download.bin";
const safe = cleaned || fallbackName;
if (safe.length <= maximumLength) return safe;
const dot = safe.lastIndexOf(".");
const extension = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : "";
let stem = safe.slice(0, Math.max(1, maximumLength - extension.length));
const finalCodeUnit = stem.charCodeAt(stem.length - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff)
stem = stem.slice(0, -1);
return `${stem || "_"}${extension}`;
}
function cleanFilename(input: string): string {
const normalized = assertBoundedText(
input,
4096,
"Filename length",
).normalize("NFC");
const cleaned = Array.from(normalized, (character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 ||
(codePoint >= 127 && codePoint <= 159) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
'/\\:*?"<>|'.includes(character)
? "_"
: character;
})
.join("")
.replace(/\s+/gu, " ")
.replace(/^\.+|[. ]+$/gu, "")
.trim();
return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)
? `_${cleaned}`
: cleaned;
}
export function createObjectUrlLease(
blob: Blob,
urlApi: Pick<typeof URL, "createObjectURL" | "revokeObjectURL"> = URL,
): ObjectUrlLease {
const url = urlApi.createObjectURL(blob);
let revoked = false;
return {
url,
get revoked() {
return revoked;
},
revoke() {
if (revoked) return;
revoked = true;
urlApi.revokeObjectURL(url);
},
};
}
export function triggerBlobDownload(
blob: Blob,
filename: string,
ownerDocument: Document = document,
): ObjectUrlLease {
const lease = createObjectUrlLease(blob);
const anchor = ownerDocument.createElement("a");
anchor.href = lease.url;
anchor.download = sanitizeDownloadFilename(filename);
anchor.rel = "noopener";
anchor.hidden = true;
ownerDocument.body.append(anchor);
try {
anchor.click();
} finally {
anchor.remove();
globalThis.setTimeout(() => lease.revoke(), 0);
}
return lease;
}