diff --git a/server/services/s3.test.ts b/server/services/s3.test.ts index 7b3e7a30..06a63d47 100644 --- a/server/services/s3.test.ts +++ b/server/services/s3.test.ts @@ -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() }) diff --git a/server/services/s3.ts b/server/services/s3.ts index d4928268..214c05f6 100644 --- a/server/services/s3.ts +++ b/server/services/s3.ts @@ -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 { 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 { 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 @@ -291,6 +297,10 @@ async function bodyToBytes(body: unknown): Promise { throw new Error('Unsupported object body') } +async function streamToBytes(body: ReadableStream): Promise { + 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