refactor: consolidate reply/media helpers
This commit is contained in:
125
src/media/fetch.ts
Normal file
125
src/media/fetch.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
|
||||
type FetchMediaResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
type FetchMediaOptions = {
|
||||
url: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
filePathHint?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
return value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(
|
||||
header?: string | null,
|
||||
): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
|
||||
if (starMatch?.[1]) {
|
||||
const cleaned = stripQuotes(starMatch[1].trim());
|
||||
const encoded = cleaned.split("''").slice(1).join("''") || cleaned;
|
||||
try {
|
||||
return path.basename(decodeURIComponent(encoded));
|
||||
} catch {
|
||||
return path.basename(encoded);
|
||||
}
|
||||
}
|
||||
const match = /filename\s*=\s*([^;]+)/i.exec(header);
|
||||
if (match?.[1]) return path.basename(stripQuotes(match[1].trim()));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readErrorBodySnippet(
|
||||
res: Response,
|
||||
maxChars = 200,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (!text) return undefined;
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) return undefined;
|
||||
if (collapsed.length <= maxChars) return collapsed;
|
||||
return `${collapsed.slice(0, maxChars)}…`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRemoteMedia(
|
||||
options: FetchMediaOptions,
|
||||
): Promise<FetchMediaResult> {
|
||||
const { url, fetchImpl, filePathHint } = options;
|
||||
const fetcher = fetchImpl ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetcher(url);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch media from ${url}: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
||||
const redirected =
|
||||
res.url && res.url !== url ? ` (redirected to ${res.url})` : "";
|
||||
let detail = `HTTP ${res.status}${statusText}`;
|
||||
if (!res.body) {
|
||||
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
||||
} else {
|
||||
const snippet = await readErrorBodySnippet(res);
|
||||
if (snippet) detail += `; body: ${snippet}`;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to fetch media from ${url}${redirected}: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
let fileNameFromUrl: string | undefined;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const base = path.basename(parsed.pathname);
|
||||
fileNameFromUrl = base || undefined;
|
||||
} catch {
|
||||
// ignore parse errors; leave undefined
|
||||
}
|
||||
|
||||
const headerFileName = parseContentDispositionFileName(
|
||||
res.headers.get("content-disposition"),
|
||||
);
|
||||
let fileName =
|
||||
headerFileName ||
|
||||
fileNameFromUrl ||
|
||||
(filePathHint ? path.basename(filePathHint) : undefined);
|
||||
|
||||
const filePathForMime =
|
||||
headerFileName && path.extname(headerFileName)
|
||||
? headerFileName
|
||||
: (filePathHint ?? url);
|
||||
const contentType = await detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: filePathForMime,
|
||||
});
|
||||
if (fileName && !path.extname(fileName) && contentType) {
|
||||
const ext = extensionForMime(contentType);
|
||||
if (ext) fileName = `${fileName}${ext}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType: contentType ?? undefined,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user