fix(engine): validate remote download integrity (#2938)

This commit is contained in:
James Russo
2026-08-04 18:02:01 -07:00
committed by GitHub
parent 7bf425b7a9
commit bbdfee1166
11 changed files with 2219 additions and 133 deletions
+7 -2
View File
@@ -399,7 +399,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
await import("../../../producer/src/services/deterministicFonts.js");
const { prepareAnimatedGifInputs } =
await import("../../../producer/src/services/animatedGifPrep.js");
const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js");
const { downloadToTemp, writeUrlDownloadTelemetry } =
await import("../../../producer/src/utils/urlDownloader.js");
const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif");
const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads");
const prepared = await prepareAnimatedGifInputs(html, {
@@ -408,7 +409,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
outputDir: gifOutputDir,
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
cacheDir: gifOutputDir,
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp),
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, (url, destDir) =>
downloadToTemp(url, destDir, undefined, undefined, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
}),
),
});
return injectDeterministicFontFaces(prepared.html);
},
+12 -1
View File
@@ -273,7 +273,18 @@ export {
type KeyframeAnalysis,
} from "./utils/ffprobe.js";
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export {
assertPublicHttpsUrl,
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
writeUrlDownloadTelemetry,
type SafeDownloadUrlIdentity,
type UrlDownloadOptions,
type UrlDownloadTelemetry,
type PublicHttpsTextOptions,
} from "./utils/urlDownloader.js";
export {
runFfmpeg,
formatFfmpegError,
@@ -52,6 +52,7 @@ describe("processCompositionAudio", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.unstubAllGlobals();
runFfmpegMock.mockClear();
extractAudioMetadataMock.mockReset();
extractAudioMetadataMock.mockResolvedValue({
@@ -66,6 +67,44 @@ describe("processCompositionAudio", () => {
}
});
it("classifies an HTML-as-200 audio source as deterministic user input", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
tempDirs.push(baseDir, workDir);
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>"));
vi.stubGlobal("fetch", fetchMock);
const result = await processCompositionAudio(
[
{
id: "remote-voice",
src: "https://cdn.example/voice",
start: 0,
end: 2,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
baseDir,
workDir,
join(baseDir, "out.m4a"),
2,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.failures).toEqual([
expect.objectContaining({
stage: "download",
owner: "user",
retryable: false,
}),
]);
});
it.each([
{
message: "AbortError: ffprobe operation aborted",
+22 -10
View File
@@ -9,7 +9,12 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeF
import { join, dirname } from "path";
import { parseHTML } from "linkedom";
import { extractAudioMetadata } from "../utils/ffprobe.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -241,16 +246,23 @@ function probeFailure(message: string, elementId: string): AudioProcessingFailur
};
}
function downloadFailure(message: string, elementId: string): AudioProcessingFailure {
function downloadFailure(error: unknown, elementId: string): AudioProcessingFailure {
const message = error instanceof Error ? error.message : String(error);
const invalidSource =
/(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
error instanceof UrlDownloadError
? error.kind === "http_not_found" ||
error.kind === "http_rejected" ||
error.kind === "invalid_payload" ||
error.kind === "cancelled"
: /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
const retryable = error instanceof UrlDownloadError ? error.retryable : !invalidSource;
return {
stage: "download",
reason: "download_failed",
owner: invalidSource ? "user" : "system",
retryable: !invalidSource,
retryable,
elementId,
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
};
@@ -712,11 +724,11 @@ export async function processCompositionAudio(
if (isHttpUrl(srcPath)) {
try {
srcPath = await downloadToTemp(srcPath, workDir);
srcPath = await downloadToTemp(srcPath, workDir, undefined, signal, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
});
} catch (err: unknown) {
failures.push(
downloadFailure(err instanceof Error ? err.message : String(err), element.id),
);
failures.push(downloadFailure(err, element.id));
return;
}
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
import { UrlDownloadError } from "../utils/urlDownloader.js";
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
describe("classifyFfmpegSpawnError", () => {
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
@@ -18,3 +19,21 @@ describe("classifyFfmpegSpawnError", () => {
});
});
});
describe("classifyVideoExtractionError download integrity", () => {
it("keeps deterministic HTML payloads non-retryable and user-owned as invalid media", () => {
const classified = classifyVideoExtractionError(
new UrlDownloadError("invalid_payload", false, "HTML payload"),
);
expect(classified).toMatchObject({ kind: "invalid_media", retryable: false });
});
it.each(["range_protocol", "length_mismatch", "hash_mismatch"] as const)(
"keeps %s retryable after the downloader's one clean refetch is exhausted",
(kind) => {
expect(
classifyVideoExtractionError(new UrlDownloadError(kind, true, "integrity failure")),
).toMatchObject({ kind: "download_transient", retryable: true });
},
);
});
@@ -28,7 +28,12 @@ import {
isHdrColorSpace as isHdrColorSpaceUtil,
type HdrTransfer,
} from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -342,6 +347,14 @@ export function classifyVideoExtractionError(error: unknown): VideoSourceExtract
diagnostic,
);
}
if (error.kind === "invalid_payload") {
return new VideoSourceExtractionError(
"invalid_media",
false,
"Video source download returned a non-media payload",
diagnostic,
);
}
if (error.retryable) {
return new VideoSourceExtractionError(
"download_transient",
@@ -1424,8 +1437,13 @@ export async function extractAllVideoFrames(
if (isHttpUrl(videoPath)) {
const downloadDir = join(options.outputDir, "_downloads");
mkdirSync(downloadDir, { recursive: true });
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () =>
recordTransientRetries(1),
videoPath = await downloadToTemp(
videoPath,
downloadDir,
undefined,
signal,
() => recordTransientRetries(1),
{ onTelemetry: writeUrlDownloadTelemetry },
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -85,6 +85,22 @@ describe("discoverMediaFromBrowser", () => {
});
});
function validTestMediaResponse(): Response {
const bytes = new Uint8Array([
0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0, 0x69, 0x73, 0x6f, 0x6d,
0x6d, 0x70, 0x34, 0x32,
]);
return new Response(bytes, { status: 200 });
}
function validTestImageResponse(): Response {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
return new Response(png, { status: 200 });
}
describe("injectSdkPositionEditsRenderScript", () => {
it("injects before </body> when SDK position-edit markers are present", () => {
const html =
@@ -1424,7 +1440,7 @@ describe("localizeRemoteMediaSources", () => {
it("rewrites remote <video> src to _remote_media path when download succeeds", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestMediaResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-ok-"));
const html = `<video id="v1" src="https://media-ok.example.com/a/clip.mp4" data-start="0" data-end="10" muted></video>`;
@@ -1447,13 +1463,42 @@ describe("localizeRemoteMediaSources", () => {
expect(remoteMediaAssets.size).toBe(0);
});
it("logs only a safe fingerprint and host for a signed-URL media failure", async () => {
const originalFetch = globalThis.fetch;
const originalWarn = defaultLogger.warn;
const warnings: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () =>
new Response("<!doctype html><html><body>expired</body></html>", { status: 200 });
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-safe-log-"));
const url = "https://cdn.example/private/customer.mp4?X-Amz-Signature=super-secret-signature";
const html = `<video id="v1" src="${url}" data-start="0" data-end="10"></video>`;
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
expect(result).toContain(url);
expect(remoteMediaAssets.size).toBe(0);
expect(warnings).toHaveLength(1);
const serialized = JSON.stringify(warnings);
expect(serialized).toContain("cdn.example");
expect(serialized).toContain("urlFingerprint");
expect(serialized).not.toContain("customer.mp4");
expect(serialized).not.toContain("super-secret-signature");
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = originalFetch;
defaultLogger.warn = originalWarn;
}
});
it("deduplicates: two tags with the same src URL → one download", async () => {
const orig = globalThis.fetch;
let fetchCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestMediaResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-dedup-"));
@@ -1479,7 +1524,7 @@ describe("localizeRemoteMediaSources", () => {
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestMediaResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-quotes-"));
const html = `<video id="v1" src="https://q.example.com/c/dq.mp4" data-start="0" data-end="10" muted></video>
@@ -1520,7 +1565,7 @@ describe("localizeRemoteImageSources", () => {
it("rewrites remote <img> src to _remote_media path when download succeeds", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-ok-"));
const html = `<img class="hero" src="https://img-ok.example.com/photo.png" />`;
@@ -1549,7 +1594,7 @@ describe("localizeRemoteImageSources", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestImageResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-dedup-"));
@@ -1583,7 +1628,7 @@ describe("localizeRemoteImageSources", () => {
it("rewrites both double-quoted and single-quoted src attributes", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-quotes-"));
const html = `<img src="https://q-img.example.com/dq.png" />
@@ -1607,7 +1652,7 @@ describe("localizeRemoteImageSources", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestImageResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-datasrc-"));
@@ -1628,7 +1673,7 @@ describe("localizeRemoteImageSources", () => {
// <img> tags with `class` before `src`. Regex must not assume src position.
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-attr-order-"));
const html = `<img class="kobe-cutout" alt="kobe" src="https://astral.example.com/d828bca.png" />`;
@@ -1798,6 +1843,7 @@ h1 { font-size: 2rem; }`;
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
// The <link> tag should be replaced with an inline <style> containing the @font-face
expect(result).not.toContain(`href="${STYLESHEET_URL}"`);
expect(result).not.toContain(STYLESHEET_URL);
expect(result).not.toContain("<link");
expect(result).toContain("@font-face");
expect(result).toContain("CustomFont");
@@ -1828,6 +1874,57 @@ h1 { font-size: 2rem; }`;
}
});
it("rejects a stylesheet redirect to a private host before the second request", async () => {
const STYLESHEET_URL = "https://styles.example.com/fonts.css";
const orig = globalThis.fetch;
let fetchCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(null, {
status: 302,
headers: { location: "https://169.254.169.254/latest/meta-data/" },
});
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-ff-private-redirect-"));
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
expect(fetchCount).toBe(1);
expect(result).toBe(html);
expect(remoteMediaAssets.size).toBe(0);
} finally {
globalThis.fetch = orig;
}
});
it("does not log a signed stylesheet path or query on failure", async () => {
const STYLESHEET_URL =
"https://styles.example.com/private/customer.css?X-Amz-Signature=super-secret";
const originalFetch = globalThis.fetch;
const originalWarn = defaultLogger.warn;
const warnings: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () =>
new Response(null, { status: 503, statusText: STYLESHEET_URL });
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
try {
const dl = mkdtempSync(join(tmpdir(), "hf-ff-safe-style-log-"));
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
await localizeRemoteFontFaces(html, dl);
const serialized = JSON.stringify(warnings);
expect(serialized).toContain("styles.example.com");
expect(serialized).toContain("urlFingerprint");
expect(serialized).not.toContain("customer.css");
expect(serialized).not.toContain("super-secret");
} finally {
globalThis.fetch = originalFetch;
defaultLogger.warn = originalWarn;
}
});
it("keeps <link> tag when external stylesheet has no @font-face rules", async () => {
const STYLESHEET_URL = "https://cdn.example.com/reset.css";
const orig = globalThis.fetch;
+43 -49
View File
@@ -53,7 +53,13 @@ import {
analyzeKeyframeIntervals,
probeMediaProfile,
} from "@hyperframes/engine";
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import {
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
type UrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import type { Page } from "puppeteer-core";
import {
injectDeterministicFontFaces,
@@ -66,6 +72,10 @@ import { defaultLogger, type ProducerLogger } from "../logger.js";
import { assertAssetMediaTypeProfile } from "./assetMediaType.js";
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
function logRemoteDownloadTelemetry(event: UrlDownloadTelemetry): void {
defaultLogger.info("[Compiler] Remote asset download integrity", { ...event });
}
export interface CompiledComposition {
html: string;
subCompositions: Map<string, string>;
@@ -419,7 +429,9 @@ async function resolveMediaDuration(
if (isHttpUrl(src)) {
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
try {
filePath = await downloadToTemp(src, downloadDir);
filePath = await downloadToTemp(src, downloadDir, undefined, undefined, undefined, {
onTelemetry: logRemoteDownloadTelemetry,
});
} catch {
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
// The element will get duration 0 and be excluded from the render.
@@ -1306,14 +1318,17 @@ async function downloadAndRewriteUrls(
await Promise.all(
[...urlSet].map(async (url) => {
try {
const localPath = await downloadToTemp(url, remoteDir);
const localPath = await downloadToTemp(url, remoteDir, undefined, undefined, undefined, {
onTelemetry: logRemoteDownloadTelemetry,
});
urlToLocal.set(url, localPath);
} catch (err) {
defaultLogger.warn(
`[Compiler] ${warnLabel} ${url} — using original URL as fallback. ${
err instanceof Error ? err.message : String(err)
}`,
);
const identity = safeDownloadUrlIdentity(url);
defaultLogger.warn(`[Compiler] ${warnLabel} — using original URL as fallback.`, {
urlFingerprint: identity.urlFingerprint,
host: identity.host,
error: err instanceof Error ? err.message : String(err),
});
}
}),
);
@@ -1369,7 +1384,7 @@ export async function localizeRemoteMediaSources(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote media download failed for",
"Remote media download failed",
"Localized remote media source(s)",
);
}
@@ -1412,7 +1427,7 @@ export async function localizeRemoteImageSources(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote image download failed for",
"Remote image download failed",
"Localized remote image source(s)",
);
}
@@ -1450,7 +1465,7 @@ export async function localizeRemoteBackgroundImages(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote background-image download failed for",
"Remote background-image download failed",
"Localized remote background-image(s)",
// Quoted url('..')/url("..") are rewritten by downloadAndRewriteUrls' default
// replaceAll; this handles the unquoted url(https://..) form.
@@ -1492,42 +1507,18 @@ function isGoogleFontsUrl(href: string): boolean {
const MAX_STYLESHEET_BYTES = 2 * 1024 * 1024;
async function fetchExternalStylesheetCss(href: string): Promise<string | null> {
const identity = safeDownloadUrlIdentity(href);
try {
assertPublicHttpsUrl(href);
} catch {
return null;
}
try {
const response = await fetch(href, {
signal: AbortSignal.timeout(15_000),
return await fetchPublicHttpsText(href, {
maxBytes: MAX_STYLESHEET_BYTES,
timeoutMs: 15_000,
});
if (!response.ok) {
defaultLogger.warn(
`[Compiler] External stylesheet fetch failed for ${href} — HTTP ${response.status}`,
);
return null;
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > MAX_STYLESHEET_BYTES) {
defaultLogger.warn(
`[Compiler] External stylesheet too large (${contentLength} bytes): ${href}`,
);
return null;
}
const text = await response.text();
if (text.length > MAX_STYLESHEET_BYTES) {
defaultLogger.warn(
`[Compiler] External stylesheet too large (${text.length} bytes): ${href}`,
);
return null;
}
return text;
} catch (err) {
defaultLogger.warn(
`[Compiler] External stylesheet fetch failed for ${href}${
err instanceof Error ? err.message : String(err)
}`,
);
defaultLogger.warn("[Compiler] External stylesheet fetch failed — preserving link tag.", {
urlFingerprint: identity.urlFingerprint,
host: identity.host,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
@@ -1608,11 +1599,14 @@ async function inlineExternalFontStylesheets(html: string): Promise<string> {
if (css === null) continue;
const fontFaceBlocks = extractFontFaceBlocks(css);
if (fontFaceBlocks.length === 0) continue;
const inlineStyle = `<style>/* Inlined from ${href} */\n${fontFaceBlocks.join("\n")}\n</style>`;
const identity = safeDownloadUrlIdentity(href);
const inlineStyle = `<style>/* Inlined external font stylesheet */\n${fontFaceBlocks.join("\n")}\n</style>`;
result = result.replace(fullMatch, inlineStyle);
defaultLogger.info(
`[Compiler] Inlined ${fontFaceBlocks.length} @font-face rule(s) from external stylesheet: ${href}`,
);
defaultLogger.info("[Compiler] Inlined external @font-face rule(s)", {
count: fontFaceBlocks.length,
urlFingerprint: identity.urlFingerprint,
host: identity.host,
});
}
return result;
}
@@ -1672,7 +1666,7 @@ export async function localizeRemoteFontFaces(
urlSet,
processed,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote font download failed for",
"Remote font download failed",
"Localized remote font face(s)",
(h, url, relPath) => h.replaceAll(`url(${url})`, `url("${relPath}")`),
);
+8 -1
View File
@@ -2,4 +2,11 @@
* Re-exported from @hyperframes/engine.
* @see engine/src/utils/urlDownloader.ts for implementation.
*/
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "@hyperframes/engine";
export {
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
writeUrlDownloadTelemetry,
type UrlDownloadTelemetry,
} from "@hyperframes/engine";