Repository navigation
Description
Summary
Stream uploads from the client straight into an S3 multipart upload, for both upload paths (tus and form-data), instead of writing the whole file to local disk and uploading it again. Account quotas must stay enforced, and the design must leave clear places to add per-upload rules later (#197).
Supersedes #163. Should also remove the causes of #202. Includes the "share post-processing between tus and form-data uploads" half of #160. Moving processing to background jobs stays in #160.
Today
- tus (
apps/api/src/tus/tus-server.ts):@tus/file-storewrites every chunk totmp/tus-uploads. On finish,finalize.tschooses the media ID, copies the file into a working folder, processes it, thenputObjects it to S3. - form-data (
apps/api/src/media/upload-guards.ts):express-fileuploadwithuseTempFiles: truewrites the file to disk, thenmedia/service.tsrepeats the same processing andputObjects it.
Every byte is written to disk at least once, read back, and sent to S3 in a single request. That costs disk space and I/O, needs sticky sessions for tus on more than one instance, and fails at the last step when S3 closes the connection (#202). The two paths also duplicate the processing code.
Decisions
- The media ID is chosen when the upload starts. The file streams straight to its final key,
{mediaId}/main.{ext}, in the private bucket, so no copy within S3 is needed. tus stores the media ID on itstus_uploadsrow at create, and form-data generates it at the start of the request. - WebP replaces the original. When WebP is on and the file is an image, finalize reads
main.{ext}back, converts it, writesmain.webp, and deletesmain.{ext}. The stored media points atmain.webp, as it does today. sizeis the size of the stored file. That's the WebP size after conversion. It's what the user is consuming, so it's what counts toward storage. The checks before and during the upload use the uploaded size, because that's all that is known then. This also fixes the bug wheresizeis currently recorded as the uploaded size.- tus uploads without a declared size (
Upload-Defer-Length) are rejected inonUploadCreate. Every upload must declare its size up front so it can be checked before any bytes arrive. - tus state:
- Postgres (
tus_uploads) keeps what MediaLit itself uses: user, app, signature, metadata, declared size, media ID, completion. - The bucket, through
@tus/s3-store's default.infoobjects, keeps tus's own resume bookkeeping (offsets, part numbers). MediaLit doesn't need to manage that. - Drop
tempFilePath. DropuploadOffsetfrom Postgres too, unless something outside tus reads it.
- Postgres (
- One shared post-upload function for both paths (from Async jobs for file transformations #160). It takes
{ mediaId, key, size, metadata, user, app, signature }and runs the final checks, WebP, thumbnails, creating the media record and cleaning up the signature. tusonUploadFinishand the form-data handler both call it. - Object tags follow the existing
DISABLE_TAGGINGflag (services/s3.ts). PassTaggingwhen the multipart upload is created unless the flag is set, so providers that reject tags keep working as they do today. - Memory and disk defaults:
- form-data (
@aws-sdk/lib-storageUpload):partSize8 MiB andqueueSize4, so at most 32 MiB of memory per upload. - tus (
@tus/s3-store):partSize8 MiB andmaxConcurrentPartUploads4, so at most about 32 MiB of buffered parts on disk per upload. - Make both configurable through environment variables, documented in
.env.example.
- form-data (
Proposal
tus
- Replace
@tus/file-storewith@tus/s3-store, configured so the object key is{mediaId}/main.{ext}. UsenamingFunctionand the upload ID mapping, or s3-store's key option. If tus can't produce that key, fall back to a staging key plus a copy within S3, and note that in the PR. - Any API instance can resume an upload, because the tus state is in the bucket and the MediaLit state is in Postgres.
form-data
- Replace
express-fileuploadtemp files withbusboystreaming into@aws-sdk/lib-storageUpload. That library uploads in parts, retries each part, and aborts on failure.
Processing
- Video thumbnails don't download the video again. The shared function passes ffmpeg a short-lived presigned URL for the object, and ffmpeg fetches only the bytes it needs for the first frame.
@medialit/thumbnailalready passes the source straight to-i, so the package doesn't change. The URL must always be one MediaLit generated, never a value from the client. - Image thumbnails and WebP read the file back from S3. Images are small, and the GIF check in
@medialit/thumbnailuses the file extension, which would break on a URL with a query string. - Keep the current API response unchanged: the
mediaheader for tus and the response body for form-data must still include the thumbnail. CourseLit, SendLit and FrontLit depend on it.
Enforcement points
Every rule runs through one shared function (extend validateUploadConstraints), so the two paths can't drift apart.
| When | tus | form-data | Rules today | Room for later (#197) |
|---|---|---|---|---|
| Before any bytes | onUploadCreate reads Upload-Length (deferred length is rejected) |
preflightUploadSize reads Content-Length |
Plan max file size, remaining storage (temp uploads count toward it) | Max size per signature, max files per signature, declared type allow-list |
| While streaming | tus server rejects bytes past Upload-Length |
busboy limits.fileSize → abort |
Hard size cap | Type check on the first chunk's bytes, ignoring the client's declared type |
| Before completing | Before CompleteMultipartUpload |
Before Upload completes |
Recheck storage (another upload may have used it) | Any rule that needs the whole file |
Rejecting at any point calls AbortMultipartUpload, so a rejected upload never becomes an object in the bucket. Storage is checked one last time against the stored size, after WebP conversion, before the media record is created.
Reliability
- Each part is retried on its own (network errors and 5xx), so one dropped connection no longer fails a whole upload.
- Add a lifecycle rule to the bucket that aborts incomplete multipart uploads after 1 day, and document it in self-hosting. Parts from uploads that are left unfinished stop costing storage. Also remove leftover tus
.infoobjects, either with s3-store's expiry or in the existing cleanup job. - The temp-file cleanup job no longer has local files to delete, which removes the
ENOENTnoise and leftover.jsonfiles from Retry S3 uploads on network errors and fix the tus cleanup ENOENT noise and leftover .json files #202.
Spike (first task)
-
@tus/s3-storeworks with AWS S3, Cloudflare R2, Backblaze B2 and MinIO. R2 requires every part except the last to be the same size, sopartSizemust stay fixed. - tus can write to the key
{mediaId}/main.{ext}(decision 1). If it can't, use the fallback described under Proposal. - Multipart uploads with
Taggingwork on each provider, orDISABLE_TAGGINGcovers the ones where they don't. - The 8 MiB part size doesn't break small or slow uploads.
- Reading an image back from S3 for thumbnails and WebP doesn't noticeably slow down the upload response.
- Video thumbnails from a presigned URL are fast on a large MP4 whose index is at the end of the file (no faststart), on S3, R2 and MinIO.
- A rollout plan for tus uploads that are in progress on disk when the new version deploys. For example, keep the file store read-only until they expire.
Acceptance criteria
- Neither upload path writes the whole file to local disk.
- tus and form-data uploads go through one shared post-upload function.
- An upload over the plan's max file size is rejected before any bytes are stored (tus and form-data).
- A tus upload with
Upload-Defer-Lengthis rejected at create. - An upload that lies about its size is cut off at the limit and nothing is stored in the bucket.
- An upload that would exceed account storage is rejected at create, and again at completion if storage ran out in the meantime.
- With WebP on, only
main.webpremains in the bucket, andsizeis the WebP file's size. - After a rejection or a client abort, no object and no unfinished multipart upload is left in the bucket (or the lifecycle rule removes it).
- An S3 connection reset during an upload is retried and the upload succeeds.
- Upload responses (
mediaheader and REST body) have the same shape as today, including thumbnails. - Making a video thumbnail doesn't download the whole video again.
- A tus upload can resume on a different API instance.
- Memory and disk use per upload stay within the configured part size × concurrency.
- The REST, MCP and CLI integration suites pass against QA, with S3 and with MinIO locally.
Activity