サーバーにファイルをアップロードする

http プラグインの fetch() と FormData で multipart 送信し、大きなファイルは Upload プラグインの upload() でパスから直接送る。進捗の受け方と Rust の reqwest で送る例も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約10分 net-006
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. <input type="file"> のファイルを multipart で送る
  4. ダイアログで選んだパスのファイルを送る
  5. Upload プラグインでパスから直接送る(進捗つき)
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

ユーザーが選んだ画像やアプリのログをサーバーへ送る方法は、サーバーが受け付ける形式とファイルの大きさで選びます。HTML フォームと同じ multipart/form-data を受けるサーバーなら、http プラグインの fetch() に FormData を渡します。ファイルの中身をそのまま本文として受ける(PUT で送る署名付き URL など)なら、Upload プラグインの upload() がパスから直接読んで、進捗つきで送ります。見落としやすいのは、upload() の送信は multipart ではないことと、fetch() は送る前にデータ全体をメモリに用意することの 2 点です。

方法本文の形式ファイルの渡し方進捗大きなファイル
fetch() + FormDatamultipartFile か読み込んだバイト列×不向き
Upload プラグインの upload()ファイルの中身そのまま絶対パス○向く
Rust の reqwestどちらも可Rust で決めたパス自作向く

前提条件

npm run tauri add http
npm run tauri add upload
# パスでファイルを選ばせて読む場合
npm run tauri add dialog
npm run tauri add fs

fetch() の送り先は http:default の allow に書きます(書き方は HTTP GET リクエストを送る(Rust経由))。upload() の権限 upload:allow-upload は upload:default にも含まれますが、送り先の URL も読み取るパスも capability では絞れません。与えたウィンドウの JS は、ユーザーが読めるどのファイルでも、どの URL へでも送れます。外部のページを表示するウィンドウには与えず、送り先を固定したいなら 2 章のように Rust で送ります。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://upload.example.com/*" }]
    },
    "upload:allow-upload",
    "dialog:allow-open",
    "fs:allow-read-file"
  ]
}

ダイアログで選ばれたファイルは起動中だけ自動で fs のスコープに入るので、fs:allow-read-file に path を書く必要はありません(ファイルを開くダイアログを表示する)。決まったフォルダーのファイルを読む場合の範囲の書き方は ファイルやディレクトリを削除する を参照してください。

1. フロントエンドから実装する (TypeScript)

<input type="file"> のファイルを multipart で送る

import { fetch } from '@tauri-apps/plugin-http';

export async function uploadPicked(input: HTMLInputElement): Promise<string> {
  const file = input.files?.[0];
  if (!file) throw new Error('ファイルが選ばれていません');
  const form = new FormData();
  form.append('file', file); // ファイル名と種類(file.type)も一緒に送られる
  form.append('comment', 'Tauri から送信');
  const res = await fetch('https://upload.example.com/api/files', {
    method: 'POST',
    body: form, // Content-Type は書かない。boundary 付きの値が自動で付く
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.text();
}

Content-Type: multipart/form-data を自分で書くと、区切り文字(boundary)の無いヘッダーが優先され、サーバーが本文を分解できなくなります。fetch() は 404 などでも例外にならないので res.ok を確かめます。

ダイアログで選んだパスのファイルを送る

<input type="file"> からはファイルのパスが分かりません。upload() を使う、同じファイルを後で送り直す、といった場合は dialog プラグインの open() で選ばせます。multipart で送るなら fs の readFile() で読んで Blob にします。

import { fetch } from '@tauri-apps/plugin-http';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { basename } from '@tauri-apps/api/path';

export async function pickAndPost(): Promise<string | null> {
  const path = await open({ multiple: false, directory: false });
  if (!path) return null; // キャンセル
  const bytes = await readFile(path); // ファイル全体をメモリに読む
  const form = new FormData();
  form.append('file', new Blob([new Uint8Array(bytes)]), await basename(path)); // 第 3 引数がファイル名
  const res = await fetch('https://upload.example.com/api/files', { method: 'POST', body: form });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.text();
}

fetch() は本文全体を用意してから Rust 側へまとめて渡すので、ファイルの大きさの何倍ものメモリを使うことがあります。数十 MB を超えるファイルは次の upload() で送ります。

Upload プラグインでパスから直接送る(進捗つき)

import { upload, HttpMethod } from '@tauri-apps/plugin-upload';
import { open } from '@tauri-apps/plugin-dialog';
import { basename } from '@tauri-apps/api/path';

export async function putWithProgress(bar: HTMLProgressElement): Promise<string | null> {
  const path = await open({ multiple: false, directory: false });
  if (!path) return null;
  const name = encodeURIComponent(await basename(path));
  const headers = new Map([['Content-Type', 'application/octet-stream']]); // multipart/form-data にしない
  // 戻り値はサーバーが返した本文。2xx 以外は例外になる
  return upload(
    `https://upload.example.com/api/raw/${name}`, // 本文にファイル名は入らないので URL などで伝える
    path, // 絶対パス。Rust 側で読みながら送る
    ({ progressTotal, total }) => {
      bar.value = progressTotal / total; // total はファイルの大きさ
    },
    headers,
    HttpMethod.Put, // 省略すると POST
  );
}

upload() はファイルを少しずつ読みながら送るので、大きなファイルでもメモリをほとんど使いません。本文はファイルの中身そのもので、Content-Length にファイルの大きさが入ります。そのため multipart を前提にしたフォームの受け口(PHP の $_FILES など)では「ファイルが無い」扱いになり、Content-Type に multipart/form-data を書いても multipart にはなりません。

進捗の progress は今回送った分、progressTotal が累計、transferSpeed はおよその毎秒バイト数です。中断する API とタイムアウトはありません。パスは絶対パスで渡すので、アプリのフォルダーのファイルなら join(await appLogDir(), 'app.log') のように組み立てます。

2. バックエンドから実装する (Rust)

送り先や送ってよいファイルを JS に任せたくない場合は、Rust のコマンドで送ります。reqwest の stream 機能を有効にすると tokio::fs::File をそのまま本文にでき、読みながら送れます。Rust から multipart で送る reqwest::multipart::Form は、reqwest に multipart 機能を足すと使えます。

[dependencies]
reqwest = { version = "0.12", features = ["stream"] }
tokio = { version = "1", features = ["fs"] }
use std::path::{Component, Path};
use tauri::Manager;

/// アプリのログフォルダーにあるファイルを 1 つ、決まったサーバーに PUT する
#[tauri::command]
async fn upload_log(app: tauri::AppHandle, file_name: String) -> Result<String, String> {
    // ファイル名 1 つだけを受け付ける("../" で別の場所を読ませない)
    let mut parts = Path::new(&file_name).components();
    if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
        return Err(format!("invalid file name: {file_name}"));
    }
    let path = app.path().app_log_dir().map_err(|e| e.to_string())?.join(&file_name);
    let file = tokio::fs::File::open(&path)
        .await
        .map_err(|e| format!("{}: {e}", path.display()))?;
    let len = file.metadata().await.map_err(|e| e.to_string())?.len();

    // 送り先は Rust で固定する。ファイル名は URL のパスとして符号化される
    let mut url = reqwest::Url::parse("https://upload.example.com/api/logs/").map_err(|e| e.to_string())?;
    url.path_segments_mut()
        .map_err(|_| "invalid base url".to_string())?
        .pop_if_empty()
        .push(&file_name);

    let res = reqwest::Client::new()
        .put(url)
        .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .header(reqwest::header::CONTENT_LENGTH, len)
        .body(reqwest::Body::from(file)) // 読みながら送る(stream 機能)
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let status = res.status();
    let body = res.text().await.map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("HTTP {status}: {body}"));
    }
    Ok(body)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init())
        .plugin(tauri_plugin_upload::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .invoke_handler(tauri::generate_handler![upload_log])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

const reply = await invoke<string>('upload_log', { fileName: 'app.log' });
console.log(reply);

動作確認

送った内容をそのまま返してくれる https://httpbin.org/post と https://httpbin.org/put を送り先にすると、形式の違いが見えます。fetch() 用に allow へ https://httpbin.org/* を足します(upload() には不要です)。npm run tauri dev で起動して送ると、応答の JSON が次のようになります。

fetch() + FormData → "files": { "file": "..." }, "form": { "comment": "Tauri から送信" }
upload()           → "files": {}, "data": "..."

multipart で送ったファイルは files に、upload() で送ったファイルは本文として data に入ります。

よくあるエラーと対処法

  • upload() で送るとサーバーが「ファイルが無い」と返す: 本文が multipart ではありません。サーバー側で本文をそのまま受けるか、fetch() + FormData に切り替えます。
  • multipart なのにサーバーが本文を解析できない: Content-Type を手で書いて boundary が消えています。FormData のときはヘッダーを書きません。
  • 「url not allowed on the configured scope: …」: fetch() の送り先が allow にありません。upload() ではこのエラーは出ません。
  • 「upload.upload not allowed. Permissions associated with this command: upload:allow-upload, upload:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:upload|upload not allowed by ACL」だけになります。
  • 「request failed with status code 413: …」: upload() で 2xx 以外が返りました。413 はサーバーの上限を超えた大きさという意味で、後ろにサーバーの本文が続きます。

OS ごとの違いと注意点

  • iOS: open() で選んだファイルは既定(fileAccessMode: 'copy')でアプリ専用の領域にコピーされ、そのパスが返ります。送り終えて不要になったコピーを消すのはアプリの役目です。
  • 送り直し: 時間切れのあとに自動で送り直すなら、同じ内容を何度送っても結果が変わらない PUT にするか、サーバー側で重複を弾きます。POST の送り直しは二重登録になることがあります。時間の決め方は 通信のタイムアウト時間を設定する を参照してください。
  • ダウンロード: 逆向きの受信と保存は ファイルをダウンロードして保存する で扱います。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する