Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/public-beds-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tus/s3-store": patch
---

Handle zero byte uploads gracefully
15 changes: 12 additions & 3 deletions packages/s3-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os from 'node:os'
import fs, {promises as fsProm} from 'node:fs'
import stream, {promises as streamProm} from 'node:stream'
import type {Readable} from 'node:stream'
import {Readable} from 'node:stream'

import type AWS from '@aws-sdk/client-s3'
import {NoSuchKey, NotFound, S3, type S3ClientConfig} from '@aws-sdk/client-s3'
Expand Down Expand Up @@ -231,7 +231,7 @@ export class S3Store extends DataStore {

protected async uploadPart(
metadata: MetadataValue,
readStream: fs.ReadStream | Readable,
readStream: fs.ReadStream | Readable | Buffer,
partNumber: number
): Promise<string> {
const data = await this.client.uploadPart({
Expand Down Expand Up @@ -445,7 +445,6 @@ export class S3Store extends DataStore {
log(`[${metadata.file.id}] failed to remove chunk ${pendingChunkFilepath}`)
}
}

promises.push(Promise.reject(error))
} finally {
// Wait for all promises. We don't want to return
Expand All @@ -463,6 +462,16 @@ export class S3Store extends DataStore {
* This is where S3 concatenates all the uploaded parts.
*/
protected async finishMultipartUpload(metadata: MetadataValue, parts: Array<AWS.Part>) {
// Handle zero-byte uploads - S3 requires at least one part to complete a multipart upload
// S3 allows the last part to be 0 bytes, so we upload a single empty part
if (parts.length === 0) {
const eTag = await this.uploadPart(metadata, Buffer.alloc(0), 1)
parts.push({
ETag: eTag,
PartNumber: 1,
})
}

const response = await this.client.completeMultipartUpload({
Bucket: this.bucket,
Key: metadata.file.id,
Expand Down
12 changes: 8 additions & 4 deletions packages/s3-store/src/test/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import path from 'node:path'
import assert from 'node:assert/strict'
import {Readable} from 'node:stream'
import stream from 'node:stream/promises'

import sinon from 'sinon'

import {S3Store} from '@tus/s3-store'
import * as shared from '../../../utils/dist/test/stores.js'
import {Upload} from '@tus/utils'
import {StreamLimiter, Upload} from '@tus/utils'

const fixturesPath = path.resolve('../', '../', 'test', 'fixtures')
const storePath = path.resolve('../', '../', 'test', 'output', 's3-store')
Expand All @@ -18,6 +19,7 @@ const s3ClientConfig = {
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
},
region: process.env.AWS_REGION,
endpoint: process.env.AWS_ENDPOINT,
}

describe('S3DataStore', () => {
Expand Down Expand Up @@ -253,10 +255,12 @@ describe('S3DataStore', () => {

await store.create(upload)

const offset = await store.write(
const offset = await stream.pipeline(
Readable.from(Buffer.alloc(size)),
upload.id,
upload.offset
new StreamLimiter(999),
async (stream) => {
return store.write(stream as StreamLimiter, upload.id, upload.offset)
}
)
assert.equal(offset, size, 'Write should return 0 offset')

Expand Down