fix(webdav): speed up small fixed-length uploads

This commit is contained in:
saltbo
2026-05-13 00:26:15 -04:00
parent c444d211df
commit 2b69a02c04
2 changed files with 44 additions and 5 deletions
+33 -4
View File
@@ -392,17 +392,46 @@ describe('S3Service', () => {
)
})
it('uploads ReadableStream bodies through a presigned PUT without buffering', async () => {
it('uploads small fixed-length ReadableStream bodies directly', async () => {
mockSend.mockResolvedValueOnce({ $metadata: {} })
const body = bytesStream(new Uint8Array([1, 2, 3]))
await expect(service.putObject(storage, 'notes/test.txt', body, 'text/plain', 3)).resolves.toBe(3)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: {
Bucket: 'my-bucket',
Key: 'notes/test.txt',
Body: new Uint8Array([1, 2, 3]),
ContentType: 'text/plain',
ContentLength: 3,
},
}),
)
})
it('rejects small fixed-length streams that do not match Content-Length', async () => {
const body = bytesStream(new Uint8Array([1, 2, 3]))
await expect(service.putObject(storage, 'notes/test.txt', body, 'text/plain', 4)).rejects.toThrow(
'Request body length does not match Content-Length',
)
})
it('uploads large ReadableStream bodies through a presigned PUT without buffering', async () => {
mockSend.mockClear()
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const body = new ReadableStream()
await expect(service.putObject(storage, 'videos/test.mp4', body, 'video/mp4', 1024)).resolves.toBe(1024)
await expect(service.putObject(storage, 'videos/test.mp4', body, 'video/mp4', 1024 * 1024)).resolves.toBe(
1024 * 1024,
)
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'Content-Length': '1024',
'Content-Length': '1048576',
},
body,
})
@@ -413,7 +442,7 @@ describe('S3Service', () => {
it('fails when presigned stream upload is rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(null, { status: 403 })))
await expect(
service.putObject(storage, 'videos/test.mp4', new ReadableStream(), 'video/mp4', 1024),
service.putObject(storage, 'videos/test.mp4', new ReadableStream(), 'video/mp4', 1024 * 1024),
).rejects.toThrow('S3 stream upload failed: 403')
vi.unstubAllGlobals()
})
+11 -1
View File
@@ -15,6 +15,7 @@ import type { Storage } from '../../shared/types'
const DEFAULT_EXPIRES_IN = 3600
const MULTIPART_PART_SIZE = 5 * 1024 * 1024
const SMALL_STREAM_PUT_BUFFER_SIZE = 256 * 1024
export class S3Service {
createClient(storage: Storage): S3Client {
@@ -141,6 +142,11 @@ export class S3Service {
): Promise<number> {
if (body instanceof ReadableStream) {
if (contentLength === undefined) return this.putObjectMultipartStream(storage, key, body, contentType)
if (contentLength <= SMALL_STREAM_PUT_BUFFER_SIZE) {
const bytes = await streamToBytes(body)
if (bytes.byteLength !== contentLength) throw new Error('Request body length does not match Content-Length')
return this.putObject(storage, key, bytes, contentType)
}
await this.putObjectStream(storage, key, body, contentType, contentLength)
return contentLength
}
@@ -279,7 +285,7 @@ export class S3Service {
async function bodyToBytes(body: unknown): Promise<Uint8Array> {
if (body instanceof Uint8Array) return body
if (body instanceof ReadableStream) return new Uint8Array(await new Response(body).arrayBuffer())
if (body instanceof ReadableStream) return streamToBytes(body)
const streamBody = body as {
transformToByteArray?: () => Promise<Uint8Array>
@@ -291,6 +297,10 @@ async function bodyToBytes(body: unknown): Promise<Uint8Array> {
throw new Error('Unsupported object body')
}
async function streamToBytes(body: ReadableStream): Promise<Uint8Array> {
return new Uint8Array(await new Response(body).arrayBuffer())
}
function bodyToResponseBody(body: unknown): BodyInit {
if (body instanceof Uint8Array)
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer