Resumable uploads
Send a large file in chunks, and continue after a lost connection.
A large file can go up in chunks, one request each. A proxy's body limit then caps a chunk, not the file, and a dropped connection, a reload, or an API restart costs only the chunk in flight. The file lands exactly as POST /uploads would store it.
The dashboard does this for every file larger than uploads.chunkSize, 8mb by default.
In the dashboard
The upload bell tracks each upload:
- A dropped connection or a restarting API shows
Reconnecting, and the request is sent again after 1, 2, 4, 8, and 16 seconds. After that, the upload fails. - A failed upload offers
Retryin the bell and its toast, continuing where the server stands. - After a reload, each of your unfinished uploads shows as
Interrupted, unless another tab is sending it. A browser forgets files on reload, soResumeasks you to pick it again. Dropping the same file into the same folder continues it too. Abortdrops the bytes the server holds. So does hiding a failed or interrupted upload, unless another tab is sending it.
To send every file whole, disable POST /uploads/sessions.
Over HTTP
A session runs through these routes:
POST /uploads/sessions { "name": "Arthas.mp4", "size": 20971520 }
PATCH /uploads/sessions/[uuid] upload-offset: N, the body is the chunk at byte N
POST /uploads/sessions/[uuid]/complete lands the file, answers 201 with the record
DELETE /uploads/sessions/[uuid] drops the session and its bytes, answers 204directory is optional, as for POST /uploads. Opening a session and sending a chunk both answer with the session, whose offset is where the next chunk starts:
{
"UUID": "019...",
"directory": "films",
"name": "arthas.mp4",
"type": "video/mp4",
"size": 20971520,
"chunkSize": 8388608,
"offset": 0,
"expiresAt": 1790000000000,
"upload": null
}upload is the landed file's UUID, null until completion.
To send a 20 MiB file by hand, split it into chunks of chunkSize:
split -b 8388608 arthas.mp4 chunk.That writes chunk.aa, chunk.ab, and the last 4 MiB in chunk.ac. Open a session:
curl -X POST http://localhost:9001/uploads/sessions \
-H 'content-type: application/json' --cookie "session=..." \
-d '{"directory": "films", "name": "Arthas.mp4", "size": 20971520}'It answers 201 with the session. Put its UUID in $UUID and send each chunk with its offset in Upload-Offset:
curl -X PATCH "http://localhost:9001/uploads/sessions/$UUID" \
-H 'upload-offset: 0' --data-binary @chunk.aa --cookie "session=..."
curl -X PATCH "http://localhost:9001/uploads/sessions/$UUID" \
-H 'upload-offset: 8388608' --data-binary @chunk.ab --cookie "session=..."
curl -X PATCH "http://localhost:9001/uploads/sessions/$UUID" \
-H 'upload-offset: 16777216' --data-binary @chunk.ac --cookie "session=..."Each answers 200 with the session, its offset now 8388608, 16777216, then 20971520. Land the file:
curl -X POST "http://localhost:9001/uploads/sessions/$UUID/complete" --cookie "session=..."It answers 201 with the record, whose path is films/arthas.mp4.
The rules
- Every route needs the
collection.Uploads.createcapability. A session answers only the user who opened it, and anyone else a404. - A chunk starts at a multiple of
chunkSizeand holdschunkSizebytes, the last one the rest. A chunk pastchunkSizeis a413, any other chunk off that grid a400, and so is one withoutContent-LengthorUpload-Offset. - Chunks go in order. A chunk at any other offset is a
409whosedatais the session, answered before the body is read. Continue fromdata.offset. - Requests on one session take turns. After an API crash mid-request, the next one waits up to 20 seconds.
completedoubles as the probe: while bytes are missing, it answers409with the session. After landing, a repeat answers200with the same record, or404when you cannot read it.- The first chunk is checked against the type of the name's extension. A mismatch is a
422and ends the session. - A
422at completion, such as a file in the way of a folder, keeps the session, to complete again or to delete. - An expired session's bytes are gone, and a
PATCHorcompleteanswers404. The first answer says the session expired. Later ones, or any after a sweep, are a plain404.
In server code
The same steps are functions in ohnejs/uploads. This route lands an upload as a film's trailer, a file field:
import { defineHandler, query, readJSONBody } from 'ohnejs';
import { requireCapability } from 'ohnejs/auth';
import { completeUploadSession } from 'ohnejs/uploads';
export default defineHandler(
async ({ params }) => {
const user = await requireCapability('collection.Films.update');
const { session } = await readJSONBody<{ session: string }>();
const { record } = await completeUploadSession(session, { author: user.UUID });
await query('Films').where('UUID', params.id).updateOrThrow({ trailer: record.UUID });
return record;
},
{ handlerTimeout: false },
);- The route lifts
api.handlerTimeout, since landing a large file can outlast it. createUploadSession,readUploadSession,writeUploadChunk,completeUploadSession, andabortUploadSessiondo what the routes do, and throw the same errors.createUploadSessionrecords itsauthoras the owner. The other helpers, given anauthor, reach only that user's sessions, and without one, any session.completeUploadSessionresolves{ record, created }, andcreatedisfalseon a repeat.sweepUploadSessionsdiscards every expired session, asabortUploadSessiondoes. It skips one a request is busy with, for the next sweep.
Sizes and limits
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', 'ohnejs/uploads'],
uploads: { chunkSize: '8mb', sessionMaxAge: '1d' },
});chunkSize- the size of every chunk but the last,64kbor more, since a file's type is checked by its first64kb. Keep it under the body limit of any proxy in front of the API, such as nginx'sclient_max_body_size.- The API buffers each chunk whole, so each one in flight holds
chunkSizeof memory. - A session keeps the
chunkSizeit opened with. Lower the setting under it, and every full chunk it still has to send answers413. sessionMaxAge- how long an upload has from opening to completion. Nothing extends it.maxFileSize,maxSVGSize, andtypesare checked when the session opens.api.requestTimeoutbounds each chunk, not the whole file.
Storage support
A backend resumes uploads when it implements parts:
fshas it.@ohnejs/s3has it. KeepchunkSizeat5mbor more there, and give the bucket the lifecycle rule from its README.- Without it, opening a session answers
501, and the dashboard sends every file whole.
What goes up whole
The upload button of a media field and Replace file in a file's details send the file in one request, bounded by maxFileSize and api.requestTimeout.