useDownloadWithProgress

View source
File download composable with real-time progress monitoring, supporting JSON business validation, POST export, abort and indeterminate progress.

Usage

useDownloadWithProgress is built on the native fetch + ReadableStream and reuses @movk/nuxt's endpoint, auth, business validation, Toast and movk:api:* hook system.

When the backend returns application/json, business validation is applied and error responses are not written to a file as binary. When binary data is returned, progress is calculated from content-length; when content-length is missing, progress is null and the UI can switch to indeterminate accordingly.

<script setup lang="ts">
const { progress, status, error, download, abort } = useDownloadWithProgress()

async function handleDownload() {
  const { aborted, error: err } = await download('/api/export/report', {
    filename: 'monthly-report.pdf',
    onSuccess: name => console.log('Done', name)
  })
  if (aborted) return
  if (err) console.error(err)
}
</script>

<template>
  <UButton :loading="status === 'pending'" @click="handleDownload">Download</UButton>
  <UProgress
    v-if="status === 'pending'"
    :model-value="progress ?? undefined"
    :max="100"
  />
  <UButton v-if="status === 'pending'" @click="abort">Cancel</UButton>
</template>
Use this for large file exports, bulk downloads and similar scenarios. For small files where progress is not needed, use $api directly.

Basic Download

When content-length is known, progress updates proportionally; abort() stops the stream read:

0% · 空闲
<script setup lang="ts">
const STATUS = {
  idle: { color: 'neutral', label: '空闲' },
  pending: { color: 'info', label: '传输中' },
  success: { color: 'success', label: '完成' },
  error: { color: 'error', label: '失败' },
  aborted: { color: 'warning', label: '已取消' }
} as const

const basic = useDownloadWithProgress()
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2">
      <UButton
        :loading="basic.status.value === 'pending'"
        icon="i-lucide-download"
        @click="basic.download('/download/large')"
      >
        开始下载
      </UButton>
      <UButton
        v-if="basic.status.value === 'pending'"
        color="error"
        variant="soft"
        icon="i-lucide-x"
        @click="basic.abort"
      >
        中止
      </UButton>
      <UBadge :color="STATUS[basic.status.value].color" variant="subtle">
        {{ basic.progress.value ?? 0 }}% · {{ STATUS[basic.status.value].label }}
      </UBadge>
    </div>
    <UProgress :model-value="basic.progress.value ?? undefined" :max="100" />
    <UAlert
      v-if="basic.error.value"
      color="error"
      variant="subtle"
      :description="basic.error.value.message"
    />
  </div>
</template>

Filename Resolution

Priority order:

  1. options.filename
  2. Response header Content-Disposition (supports RFC 5987 encoding)
  3. Last segment of the URL pathname (query string is automatically stripped)
  4. 'download'
await download('/api/export?token=abc')
// Automatically uses "export" as the fallback filename; query string does not pollute the name

Indeterminate Progress

When the response is missing content-length (e.g. chunked transfer encoding), progress is null. Using nullish-coalescing to map it to UProgress's :model-value automatically activates the indeterminate state:

0% · 空闲
<script setup lang="ts">
const STATUS = {
  idle: { color: 'neutral', label: '空闲' },
  pending: { color: 'info', label: '传输中' },
  success: { color: 'success', label: '完成' },
  error: { color: 'error', label: '失败' },
  aborted: { color: 'warning', label: '已取消' }
} as const

const chunked = useDownloadWithProgress()
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2">
      <UButton
        :loading="chunked.status.value === 'pending'"
        icon="i-lucide-download"
        @click="chunked.download('/download/chunked')"
      >
        开始下载
      </UButton>
      <UBadge :color="STATUS[chunked.status.value].color" variant="subtle">
        {{ chunked.progress.value === null ? '不确定' : `${chunked.progress.value}%` }}
        · {{ STATUS[chunked.status.value].label }}
      </UBadge>
    </div>
    <UProgress :model-value="chunked.progress.value ?? undefined" :max="100" />
  </div>
</template>

Custom Filename

options.filename overrides the response header and URL inference to force a specific download filename:

空闲
<script setup lang="ts">
const STATUS = {
  idle: { color: 'neutral', label: '空闲' },
  pending: { color: 'info', label: '传输中' },
  success: { color: 'success', label: '完成' },
  error: { color: 'error', label: '失败' },
  aborted: { color: 'warning', label: '已取消' }
} as const

const custom = useDownloadWithProgress()
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2">
      <UButton
        :loading="custom.status.value === 'pending'"
        icon="i-lucide-file-down"
        @click="custom.download('/download/large', { filename: 'custom-name.bin' })"
      >
        自定义文件名下载
      </UButton>
      <UBadge :color="STATUS[custom.status.value].color" variant="subtle">
        {{ STATUS[custom.status.value].label }}
      </UBadge>
    </div>
    <UAlert
      v-if="custom.error.value"
      color="error"
      variant="subtle"
      :description="custom.error.value.message"
    />
  </div>
</template>

POST Export

method: 'POST' + body puts filter criteria in the request body; Content-Type is automatically set to application/json:

空闲
<script setup lang="ts">
const STATUS = {
  idle: { color: 'neutral', label: '空闲' },
  pending: { color: 'info', label: '传输中' },
  success: { color: 'success', label: '完成' },
  error: { color: 'error', label: '失败' },
  aborted: { color: 'warning', label: '已取消' }
} as const

const postExport = useDownloadWithProgress()
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2">
      <UButton
        :loading="postExport.status.value === 'pending'"
        icon="i-lucide-package"
        @click="postExport.download('/download/large', {
          method: 'POST',
          body: { ids: [1, 2, 3] }
        })"
      >
        POST 导出
      </UButton>
      <UBadge :color="STATUS[postExport.status.value].color" variant="subtle">
        {{ STATUS[postExport.status.value].label }}
      </UBadge>
    </div>
    <UAlert
      v-if="postExport.error.value"
      color="error"
      variant="subtle"
      :description="postExport.error.value.message"
    />
  </div>
</template>

Abort Download

const { status, download, abort } = useDownloadWithProgress()

download('/api/export/large.zip')

function handleCancel() {
  if (status.value === 'pending') abort()
}

After aborting:

  • status changes to 'aborted'; progress resets to 0
  • error remains null (not treated as failure)
  • onError is not called; no Toast is shown
  • download() returns { data: null, error: null, aborted: true }

Authentication

The token is automatically read from the session and injected into the Authorization header. Override it in headers if needed (user value takes priority).

await download('/api/export/private.pdf', {
  headers: { 'X-Custom': 'value' }
})

API

useDownloadWithProgress<T>()

useDownloadWithProgress<T = Blob>(): { progress, status, data, error, download, abort }

Returns

progress
Ref<number | null>
Download progress, 0–100. null when content-length is missing; the UI should switch to indeterminate in that case.
status
Ref<TransferStatus>
Transfer status: 'idle' | 'pending' | 'success' | 'error' | 'aborted'.
data
Ref<T | null>
In the binary path, a Blob. In the JSON path, the unwrapped business data.
error
Ref<ApiError | Error | null>
Error object. Business errors are ApiError; HTTP/network errors are Error. null on abort.
download()
(url: string, options?: DownloadWithProgressOptions) => Promise<TransferResult<T>>
Execute the download.
abort()
() => void
Abort the current download; status switches to 'aborted'.

Changelog

No recent changes
Copyright © 2025 - 2026 YiXuan - MIT License