pion_resumable_upload

pub package license: MIT

Resumable, fault-tolerant chunked file uploads for Dart and Flutter that speak the resumable.js wire protocol, so they work against a Laravel pion/laravel-chunk-upload backend without any server changes.

If your web client already uploads with resumable.js to a Laravel chunk-upload endpoint, this package lets a Dart/Flutter client hit the same endpoint with a byte-for-byte compatible payload.

Features

  • 🧩 Protocol parity β€” emits the exact resumableChunkNumber, resumableTotalChunks, resumableIdentifier, … fields the ResumableJSUploadHandler expects.
  • πŸͺΆ Low memory β€” reads each chunk with a sliding window over RandomAccessFile; a multi-gigabyte file never lands in RAM.
  • ⚑ Concurrent β€” uploads several chunks at once (configurable).
  • πŸ” Resumable β€” a GET pre-flight skips chunks already on the server; chunks purged by the backend cleanup cron are re-uploaded automatically.
  • πŸ›‘οΈ Resilient β€” exponential backoff with jitter, with transient vs. permanent error classification.
  • ⏯️ Controllable β€” pause, resume and cancel, with an interruptible backoff.
  • πŸ’Ύ Persistable β€” plug in any store (Hive, sqflite, a file, …) to resume uploads after an app restart.
  • πŸ”‘ Stateless auth β€” static headers and/or a per-request token provider so expiring JWTs can be refreshed mid-upload.

Supported platforms

Android, iOS, Windows, macOS and Linux. The package uses dart:io (RandomAccessFile) for memory-efficient reads, so the web is not supported.

Installation

dependencies:
  pion_resumable_upload: ^0.1.0
dart pub add pion_resumable_upload

Quick start

import 'dart:io';
import 'package:pion_resumable_upload/pion_resumable_upload.dart';

Future<void> main() async {
  final client = UploadClient(
    config: UploadConfig(
      uploadUrl: Uri.parse('https://api.example.com/upload'),
    ),
  );

  final task = client.createTask(File('/path/to/large_video.mp4'));

  task.progress.listen((p) {
    print('${p.percentage.toStringAsFixed(1)}% '
        '(${p.completedChunks}/${p.totalChunks} chunks) β€” ${p.state.name}');
  });

  try {
    await task.start(); // completes when the whole file is uploaded
    print('Done!');
  } on ResumableUploadException catch (e) {
    print('Upload failed: $e');
  } finally {
    await client.close();
  }
}

Pause, resume and cancel

final task = client.createTask(file);
final done = task.start();

task.pause();          // stops scheduling, aborts in-flight chunks & backoff
await task.resume();   // continues from where it left off
task.cancel();         // aborts; `done` completes with UploadCancelledException

await done;

start()/resume() return a future that completes on success and completes with an error on failure or cancellation. Pausing does not settle it β€” resuming carries on toward the terminal state.

Configuration

Option Default Description
uploadUrl required Endpoint for both the GET pre-flight and the chunk POST.
chunkSize 1048576 (1 MiB) Target chunk size in bytes.
forceChunkSize false false: the last chunk absorbs the remainder (resumable.js behaviour). true: every chunk is exactly chunkSize.
simultaneousUploads 3 Max chunks uploaded concurrently.
testChunks true Send a GET pre-flight to skip chunks already present.
uploadMethod POST POST, PUT or PATCH.
fileParameterName file Multipart field name for the binary chunk.
maxChunkRetries 5 Retry attempts per chunk.
baseRetryDelay / maxRetryDelay / maxRetryJitter 1s / 30s / 500ms Backoff parameters.
headers / authHeaderProvider {} / null Static and per-request headers.
extraParameters {} Extra fields added to every request.
successStatuses [200, 201, 202] Status codes meaning "accepted / already present".
permanentErrorStatuses [400, 404, 415, 500, 501] Non-retryable status codes.
identifierGenerator MD5 of size-name Strategy for resumableIdentifier.
filenameSanitizer strip + truncate ≀100 Strategy for resumableFilename.

Authentication (JWT / bearer)

Because the backend is typically a stateless API, pass a token via headers. Use authHeaderProvider so a token that expires during a long upload is refreshed per request:

UploadConfig(
  uploadUrl: Uri.parse('https://api.example.com/upload'),
  authHeaderProvider: () async => {
    'Authorization': 'Bearer ${await tokenStore.freshAccessToken()}',
  },
);

Resuming across app restarts

The default store keeps progress in memory only. Implement UploadStateStore (backed by Hive, sqflite, a file, …) and pass it to the client to survive a full restart:

class MyStore implements UploadStateStore {
  // load / save / markChunkComplete / removeChunk / delete
}

final client = UploadClient(config: config, store: MyStore());

Recreate the task for the same file after a restart and call start(); with testChunks enabled, every chunk is re-verified, so already-uploaded chunks are skipped and any purged ones are re-uploaded.

Custom transport

Swap the HTTP layer (e.g. to add Dio interceptors or logging) by implementing UploadTransport, or pass a pre-configured Dio to DioUploadTransport:

final client = UploadClient(
  config: config,
  transport: DioUploadTransport(config: config, dio: myDio),
);

Laravel backend setup

Install the package and route a controller to a FileReceiver with the ResumableJSUploadHandler. For mobile clients, also:

  • Disable session locking (concurrent chunks otherwise serialize): use a custom handler whose canUseSession() returns false, registered under the override key in config/chunk-upload.php.
  • Keep chunkSize under post_max_size / upload_max_filesize in php.ini. The 1 MiB default is safe almost everywhere.
  • This package already truncates long file names to avoid the ext4 255-byte limit, but applying the same guard server-side is good defence in depth.

See the pion/laravel-chunk-upload wiki for full backend configuration.

How it works

  • Chunk offset: offset = (chunkNumber - 1) Γ— chunkSize.
  • Chunk count: ceil(size / chunkSize) when forceChunkSize is true, otherwise max(1, floor(size / chunkSize)) (so the last chunk absorbs the remainder, matching resumable.js).
  • Backoff: delay(n) = min(maxDelay, baseDelay Γ— 2ⁿ) + jitter.

Example

See example/ for a small Flutter app that picks a file and shows upload progress with pause / resume / cancel controls.

License

MIT

Libraries

pion_resumable_upload
Resumable, fault-tolerant chunked file uploads for Dart.