S3 storage
A container on Cloud Run, Fargate, Kubernetes or Fly.io loses its disk on
every redeploy, and two replicas never see each other's files. daravel_s3
puts the Storage facade on a bucket instead: AWS S3 or any S3-compatible
store (Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi,
Google Cloud Storage through its interoperability API). It signs requests
with AWS Signature Version 4 in pure Dart, so it adds no AWS SDK and
compiles into the same single AOT binary.
dart pub add daravel_s3
// bootstrap/app.dart
import 'package:daravel_s3/daravel_s3.dart';
Application createApp() => Application.configure(basePath: basePath)
.withS3()
// ...
.create();
The variables are the ones Laravel's config/filesystems.php reads, so a
Laravel and a Daravel application can share one .env and one bucket:
FILESYSTEM_DISK=s3 # every Storage call goes to the bucket
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=ap-southeast-1
AWS_BUCKET=my-app-files
AWS_URL=https://cdn.example.com # optional public base URL (a CDN)
AWS_VISIBILITY=private # or public: objects get x-amz-acl public-read
# AWS_SESSION_TOKEN=... # temporary credentials
With FILESYSTEM_DISK=s3 boot fails while the key, the secret or the bucket
is missing. Without it the bucket is still there as Storage.disk('s3')
next to local and public. withS3(disk: 'media') registers it under
another name.
Other providers#
| Provider | AWS_ENDPOINT |
AWS_DEFAULT_REGION |
AWS_USE_PATH_STYLE_ENDPOINT |
|---|---|---|---|
| AWS S3 | (unset) | the bucket's region | false |
| Cloudflare R2 | https://<account-id>.r2.cloudflarestorage.com |
auto |
false |
| MinIO | http://127.0.0.1:9000 |
us-east-1 |
true |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com |
the region (sgp1) |
false |
| Google Cloud Storage | https://storage.googleapis.com |
auto |
true |
Path-style addressing (endpoint/bucket/key) is also the way to reach a
bucket whose name contains dots over HTTPS.
Using the disk#
Every Disk method works on the bucket, with the same results as on a
local disk (the package runs one contract test suite against LocalDisk,
MemoryDisk and S3Disk):
await Storage.disk('s3').put('avatars/${user.id}.png', bytes);
final png = await Storage.disk('s3').getBytes('avatars/${user.id}.png');
await Storage.disk('s3').copy('a.pdf', 'archive/a.pdf'); // copied inside S3
await Storage.disk('s3').delete(['old/1.txt', 'old/2.txt']); // one request per 1000
final files = await Storage.disk('s3').allFiles('reports');
Objects are stored with a Content-Type from their extension, so a browser
opening their URL renders them. Directories are key prefixes;
makeDirectory writes the empty dir/ object Laravel writes, and both
frameworks list the same tree. append reads the object and writes it back,
since S3 cannot append.
url(path) is AWS_URL/key when AWS_URL is set and the bucket's own URL
otherwise. It is only reachable when the object is public (bucket policy or
AWS_VISIBILITY=public); for private objects use a temporary URL.
Streaming large files#
putStream keeps at most one part (8 MiB) in memory. A stream that fits in
one part is sent as a single PUT; a longer one becomes a multipart upload,
which is aborted if the stream fails, so a 2 GB recording never sits in
RAM. Uploaded files are stored this way, from their temporary file:
router.post('/recordings', (request) async {
final video = await request.file('video');
final path = await video!.store('recordings', disk: 's3');
return {'path': path};
});
readStream hands the download to the response as it arrives:
return Response.stream(
Storage.disk('s3').readStream('recordings/$id.mp4'),
contentType: 'video/mp4',
);
Temporary URLs#
A temporary (presigned) URL lets a client download a private object straight from the bucket until it expires (at most seven days), without the bytes passing through your server:
final url = Storage.disk('s3').temporaryUrl(
'invoices/1029.pdf',
expiration: 30.minutes.fromNow,
responseContentDisposition: 'attachment; filename="invoice-1029.pdf"',
);
The expiry follows Daravel's clock, so Clock.fake() in a test fixes it.
Direct uploads#
For large uploads, a client asks your server for a presigned PUT and sends
the file straight to the bucket:
router.post('/api/uploads', (request) async {
final path = 'materials/${Str.uuid()}.mp4';
final upload = Storage.disk('s3').temporaryUploadUrl(
path,
expiration: 15.minutes.fromNow,
contentType: 'video/mp4',
);
return {'path': path, ...upload.toJson()}; // method, url, headers
});
The client sends the file with exactly the returned headers:
// Flutter, package:http
await http.put(Uri.parse(json['url']), headers: json['headers'], body: bytes);
contentType and contentLength are part of the signature: S3 rejects an
upload with another type, or another size when contentLength is given. A
presigned PUT cannot cap the size, only fix it.
Capping the size: a POST form#
To let a client upload up to a size, hand out a presigned POST form
instead (S3's POST policy). The policy is signed, and S3 checks its
conditions on upload: a file over maxSize, a content type that does not
start with contentTypePrefix, or any changed field is refused.
router.post('/api/uploads', (request) async {
final path = 'materials/${Str.uuid()}.mp4';
final form = Storage.disk('s3').temporaryUploadForm(
path,
expiration: 15.minutes.fromNow,
maxSize: 500 * 1024 * 1024, // 500 MB
contentTypePrefix: 'video/', // or contentType: 'video/mp4', exactly
);
return {'path': path, ...form.toJson()}; // method, url, fields
});
The client sends fields unchanged as multipart/form-data to url, adds
Content-Type when a prefix was given, and puts the file last, in a field
named file:
// Flutter, package:http
final upload = http.MultipartRequest('POST', Uri.parse(json['url']))
..fields.addAll({...json['fields'], 'Content-Type': 'video/mp4'})
..files.add(await http.MultipartFile.fromPath('file', video.path));
final response = await upload.send(); // 204 when S3 stored it
A browser does the same with a <form method="post" enctype="multipart/form-data">
or a FormData.
Testing#
Storage.fake('s3') replaces the bucket with an in-memory disk as for any
disk. temporaryUrl, temporaryUploadUrl and temporaryUploadForm work on
a fake too and return a placeholder
(/storage/s3/<path>?expiration=<unix time>), so routes that hand out URLs
can be tested without a network:
final disk = Storage.fake('s3');
final response = (await client.post('/api/uploads')).assertOk();
final body = response.json! as Map<String, Object?>;
expect(body['url'], startsWith('/storage/s3/materials/'));
disk.assertMissing(body['path']! as String); // the client uploads it
Against a real server, the package's integration suite runs on RustFS in
CI, or on any S3-compatible server:
DARAVEL_TEST_S3=http://127.0.0.1:9000 dart test --tags integration in
packages/daravel_s3.
On the wire#
A request may take timeout (5 minutes) to send its body and get its
response headers, and a response body may go quiet for that long; past it
the request is aborted or the body cut off with a ConnectionException
(isTimeout), and the socket is not returned to the pool. A call that S3
answers 500, 502, 503 (SlowDown) or 504, or whose connection
fails before any response, is sent again up to retries (3) more times
after retryDelay (100 ms) doubled each time with jitter, as the AWS
SDKs do; every call carries its whole body, so a resend is exact.
Limits#
copyandmoveuse S3's server-side copy, which takes objects up to 5 GB.- Credentials come from
.env(orAWS_SESSION_TOKENfor temporary ones); instance roles and web identity tokens are not read yet. - Every part but the last of a multipart upload must be at least 5 MiB;
S3Disk(..., partSize: ...)changes the 8 MiB default.
Laravel equivalents#
More of Laravel that this works with, and the API to look up on pub.dev.
| Laravel | Daravel | API | Note |
|---|---|---|---|
Storage read-through driver |
ReadThroughDisk(primary: a, fallback: b) |
ReadThroughDisk |