diff --git a/.changeset/curly-files-range.md b/.changeset/curly-files-range.md new file mode 100644 index 00000000000..aa2a20ade46 --- /dev/null +++ b/.changeset/curly-files-range.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Apply byte range and chunk size options to default Web file responses. diff --git a/packages/effect/src/unstable/http/HttpPlatform.ts b/packages/effect/src/unstable/http/HttpPlatform.ts index ad4d07b9e60..c6d056696e6 100644 --- a/packages/effect/src/unstable/http/HttpPlatform.ts +++ b/packages/effect/src/unstable/http/HttpPlatform.ts @@ -156,14 +156,42 @@ export const layer = Layer.effect(HttpPlatform)( { contentLength, headers, status, statusText } ) }, - fileWebResponse(file, status, statusText, headers, _options) { - return Response.stream( - Stream.fromReadableStream({ + fileWebResponse(file, status, statusText, headers, options) { + const offset = Number(options?.offset ?? 0) + const bytesToRead = options?.bytesToRead !== undefined ? Number(options.bytesToRead) : undefined + const chunkSize = options?.chunkSize !== undefined ? Math.max(1, Number(options.chunkSize)) : Infinity + const end = offset + (bytesToRead ?? Infinity) + const stream = end <= offset + ? Stream.empty + : Stream.fromReadableStream({ evaluate: () => file.stream() as ReadableStream, onError: identity - }), - { headers, status, statusText } - ) + }).pipe( + Stream.mapAccum( + () => 0, + (position, bytes) => { + const next = position + bytes.length + const start = Math.min(Math.max(offset - position, 0), bytes.length) + const stop = Math.min(Math.max(end - position, 0), bytes.length) + const chunks: Array<{ readonly bytes: Uint8Array; readonly done: boolean }> = [] + for (let index = start; index < stop; index += chunkSize) { + chunks.push({ + bytes: bytes.subarray(index, Math.min(index + chunkSize, stop)), + done: next >= end && index + chunkSize >= stop + }) + } + return [next, chunks] + } + ), + Stream.takeUntil((chunk) => chunk.done), + Stream.map((chunk) => chunk.bytes) + ) + return Response.stream(stream, { + contentLength: bytesToRead ?? file.size - offset, + headers, + status, + statusText + }) } })) ).pipe(Layer.provide(Etag.layerWeak)) diff --git a/packages/effect/test/unstable/http/HttpPlatform.test.ts b/packages/effect/test/unstable/http/HttpPlatform.test.ts new file mode 100644 index 00000000000..f5ae0cab4ac --- /dev/null +++ b/packages/effect/test/unstable/http/HttpPlatform.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, FileSystem, Stream } from "effect" +import { HttpPlatform } from "effect/unstable/http" + +describe("HttpPlatform", () => { + const file = { + name: "file.bin", + lastModified: 0, + size: 4, + type: "application/octet-stream", + stream: () => new Blob([new Uint8Array([1, 2, 3, 4])]).stream() + } + + it.effect("honors Web file offset and byte count", () => + Effect.gen(function*() { + const platform = yield* HttpPlatform.HttpPlatform + const response = yield* platform.fileWebResponse(file, { offset: 1, bytesToRead: 2 }) + assert.strictEqual(response.body._tag, "Stream") + if (response.body._tag === "Stream") { + assert.strictEqual(response.body.contentLength, 2) + const bytes = yield* Stream.mkUint8Array(response.body.stream) + assert.deepStrictEqual(Array.from(bytes), [2, 3]) + } + }).pipe( + Effect.provide(HttpPlatform.layer), + Effect.provideService(FileSystem.FileSystem, {} as any) + )) + + it.effect("honors Web file chunk size", () => + Effect.gen(function*() { + const platform = yield* HttpPlatform.HttpPlatform + const response = yield* platform.fileWebResponse(file, { offset: 0, bytesToRead: 4, chunkSize: 2 }) + assert.strictEqual(response.body._tag, "Stream") + if (response.body._tag === "Stream") { + assert.strictEqual(response.body.contentLength, 4) + const chunks = yield* Stream.runCollect(response.body.stream) + assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[1, 2], [3, 4]]) + } + }).pipe( + Effect.provide(HttpPlatform.layer), + Effect.provideService(FileSystem.FileSystem, {} as any) + )) +})