From 630db7e5de34103b15ff7fd082e494695b7abb36 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 08:15:38 -0300 Subject: [PATCH 01/11] Make fromFileZIO receive the request --- .../main/scala/zio/http/HandlerPlatformSpecific.scala | 5 ++--- zio-http/shared/src/main/scala/zio/http/Handler.scala | 11 ++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/zio-http/jvm/src/main/scala/zio/http/HandlerPlatformSpecific.scala b/zio-http/jvm/src/main/scala/zio/http/HandlerPlatformSpecific.scala index 681159b801..89b3adf00b 100644 --- a/zio-http/jvm/src/main/scala/zio/http/HandlerPlatformSpecific.scala +++ b/zio-http/jvm/src/main/scala/zio/http/HandlerPlatformSpecific.scala @@ -16,7 +16,7 @@ trait HandlerPlatformSpecific { */ def fromResource(path: String, charset: Charset = Charsets.Utf8)(implicit trace: Trace, - ): Handler[Any, Throwable, Any, Response] = + ): Handler[Any, Throwable, Request, Response] = Handler.fromZIO { ZIO .attemptBlocking(getClass.getClassLoader.getResource(path)) @@ -29,7 +29,7 @@ trait HandlerPlatformSpecific { private[zio] def fromResourceWithURL( url: java.net.URL, charset: Charset, - )(implicit trace: Trace): Handler[Any, Throwable, Any, Response] = { + )(implicit trace: Trace): Handler[Any, Throwable, Request, Response] = url.getProtocol match { case "file" => Handler.fromFile(new File(url.getPath), charset) case "jar" => @@ -70,7 +70,6 @@ trait HandlerPlatformSpecific { case proto => Handler.fail(new IllegalArgumentException(s"Unsupported protocol: $proto")) } - } /** * Attempts to retrieve files from the classpath. diff --git a/zio-http/shared/src/main/scala/zio/http/Handler.scala b/zio-http/shared/src/main/scala/zio/http/Handler.scala index f3863d0873..8bfc9af861 100644 --- a/zio-http/shared/src/main/scala/zio/http/Handler.scala +++ b/zio-http/shared/src/main/scala/zio/http/Handler.scala @@ -910,13 +910,14 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { def fromFile[R](makeFile: => File, charset: Charset = Charsets.Utf8)(implicit trace: Trace, - ): Handler[R, Throwable, Any, Response] = + ): Handler[R, Throwable, Request, Response] = { fromFileZIO(ZIO.attempt(makeFile), charset) + } def fromFileZIO[R](getFile: ZIO[R, Throwable, File], charset: Charset = Charsets.Utf8)(implicit trace: Trace, - ): Handler[R, Throwable, Any, Response] = { - Handler.fromZIO[R, Throwable, Response]( + ): Handler[R, Throwable, Request, Response] = { + Handler.fromFunctionZIO[Request] { request => ZIO.blocking { getFile.flatMap { file => if (!file.exists()) { @@ -945,8 +946,8 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { } } } - }, - ) + } + } } /** From dd5b345fd47e4796ec2caff48c0c77175c1e2dd5 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 09:20:10 -0300 Subject: [PATCH 02/11] Create RangedFileBody to create responses --- .../shared/src/main/scala/zio/http/Body.scala | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index 1c3fef1731..b1e5360c28 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -682,6 +682,33 @@ object Body { override def knownContentLength: Option[Long] = Some(fileSize) } + private[zio] final case class RangedFileBody( + file: java.io.File, + chunkSize: Int, + fileSize: Long, + start: Long, + end: Long, + override val contentType: Option[Body.ContentType] = None, + ) extends Body { + + override def asArray(implicit trace: Trace): Task[Array[Byte]] = ??? + + override def isComplete: Boolean = false + + override def isEmpty: Boolean = false + + override def asChunk(implicit trace: Trace): Task[Chunk[Byte]] = ??? + + override def asStream(implicit trace: Trace): ZStream[Any, Throwable, Byte] = { + println(s"[RangedFileBody] asStream called! file=${file.getName}, start=$start, end=$end, contentLength=$fileSize") + ZStream.fromIterable("TODO: implement response creation\n".getBytes) + } + + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) + + override def knownContentLength: Option[Long] = Some(fileSize) + } + private[zio] final case class StreamBody( stream: ZStream[Any, Throwable, Byte], knownContentLength: Option[Long], From 6fa32d319876d783238103352d8eef6e7533d5f5 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 09:20:36 -0300 Subject: [PATCH 03/11] Make Body.fromFile use RangedFileBody --- .../shared/src/main/scala/zio/http/Body.scala | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index b1e5360c28..f6b32a2f56 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -350,9 +350,22 @@ object Body { /** * Constructs a [[zio.http.Body]] from the contents of a file. */ - def fromFile(file: java.io.File, chunkSize: Int = 1024 * 4)(implicit trace: Trace): ZIO[Any, Nothing, Body] = { + def fromFile( + file: java.io.File, + chunkSize: Int = 1024 * 4, + start: Long = 0, + end: Option[Long] = None + )(implicit trace: Trace): ZIO[Any, Nothing, Body] = { ZIO.attemptBlocking(file.length()).orDie.map { fileSize => - FileBody(file, chunkSize, fileSize) + val isRangeRequest = start > 0 || end.exists(_ < fileSize - 1) + + if (isRangeRequest) { + val endByte = end.getOrElse(fileSize - 1) + val contentLength = endByte - start + 1 + RangedFileBody(file, chunkSize, contentLength, start, endByte) + } else { + FileBody(file, chunkSize, fileSize) + } } } @@ -675,7 +688,7 @@ object Body { } } } - } + } override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) From b661e07c04d2f5a1bf7674bf88fd6fdcba898738 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 09:22:39 -0300 Subject: [PATCH 04/11] Add RangedFileBody case to netty body writer --- .../jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala index 5c4a27138a..99c4db0777 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala @@ -55,6 +55,11 @@ private[netty] object NettyBodyWriter { } body match { + case body: Body.RangedFileBody => + println(s"[NettyBodyWriter] RangedFileBody detected, converting to StreamBody") + val stream = body.asStream + val s = StreamBody(stream, body.knownContentLength, contentType = body.contentType) + NettyBodyWriter.writeAndFlush(s, body.knownContentLength, ctx) case body: FileBody => // We need to stream the file when compression is enabled otherwise the response encoding fails val stream = ZStream.fromFile(body.file) From ba0a580ee393f9c89d04411d4ea9062a7693ff34 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 09:50:46 -0300 Subject: [PATCH 05/11] Refactor fromFileZIO to accept ranges --- .../src/main/scala/zio/http/Handler.scala | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Handler.scala b/zio-http/shared/src/main/scala/zio/http/Handler.scala index 8bfc9af861..8e7bf61496 100644 --- a/zio-http/shared/src/main/scala/zio/http/Handler.scala +++ b/zio-http/shared/src/main/scala/zio/http/Handler.scala @@ -908,6 +908,25 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { } } + /** + * Set MIME type in the response headers. This is only relevant in + * case of RandomAccessFile transfers as browsers use the MIME type, + * not the file extension, to determine how to process a URL. + * {{{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}}} + */ + private def addMediaType( + response: Response, + pathName: String, + charset: Charset, + ): ZIO[Any, Nothing, Response] = { + determineMediaType(pathName) match { + case Some(mediaType) => + val charset0 = if (mediaType.mainType == "text" || !mediaType.binary) Some(charset) else None + ZIO.succeed(response.addHeader(Header.ContentType(mediaType, charset = charset0))) + case None => ZIO.succeed(response) + } + } + def fromFile[R](makeFile: => File, charset: Charset = Charsets.Utf8)(implicit trace: Trace, ): Handler[R, Throwable, Request, Response] = { @@ -926,20 +945,23 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { ZIO.fail(new AccessDeniedException(file.getAbsolutePath)) } else { if (file.isFile) { - Body.fromFile(file).flatMap { body => - val response = http.Response(body = body) val pathName = file.toPath.toString - // Set MIME type in the response headers. This is only relevant in - // case of RandomAccessFile transfers as browsers use the MIME type, - // not the file extension, to determine how to process a URL. - // {{{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}}} - determineMediaType(pathName) match { - case Some(mediaType) => - val charset0 = if (mediaType.mainType == "text" || !mediaType.binary) Some(charset) else None - ZIO.succeed(response.addHeader(Header.ContentType(mediaType, charset = charset0))) - case None => ZIO.succeed(response) - } + (request.header(Header.Range) match { + case Some(rangeHeader) => + ZIO.attemptBlocking(file.length()).orDie.flatMap { fileSize => + serveFileWithRange(file, fileSize, rangeHeader, pathName) + } + + case None => + Body.fromFile(file).map { body => + Response( + body = body, + headers = Headers(Header.AcceptRanges.Bytes), + ) + } + }).flatMap { response => + addMediaType(response, pathName, charset) } } else { ZIO.fail(new NotDirectoryException(s"Found directory instead of a file.")) From 95ece2b5995b6dd24f355f2e9e494cb842ca7e04 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Thu, 1 Jan 2026 09:51:12 -0300 Subject: [PATCH 06/11] add serveFileWithRange --- .../src/main/scala/zio/http/Handler.scala | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Handler.scala b/zio-http/shared/src/main/scala/zio/http/Handler.scala index 8e7bf61496..3c06896ee2 100644 --- a/zio-http/shared/src/main/scala/zio/http/Handler.scala +++ b/zio-http/shared/src/main/scala/zio/http/Handler.scala @@ -927,6 +927,48 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { } } + private def serveFileWithRange( + file: File, + fileSize: Long, + rangeHeader: Header.Range, + pathName: String, + )(implicit trace: Trace): ZIO[Any, Throwable, Response] = { + rangeHeader match { + case Header.Range.Single(unit, start, endOpt) if unit == "bytes" => + val end = endOpt.getOrElse(fileSize - 1) + + if (start >= fileSize || start < 0 || end >= fileSize || start > end) { + println(s"[RANGE] invalid range: start=$start, end=$end, fileSize=$fileSize") + ZIO.succeed( + Response( + status = Status.RequestedRangeNotSatisfiable, + headers = Headers(Header.ContentRange.RangeTotal("bytes", fileSize.toInt)), + ), + ) + } else { + println(s"[RANGE] serving bytes $start-$end de $fileSize") + Body.fromFile(file, start = start, end = Some(end)).map { body => + Response( + status = Status.PartialContent, + body = body, + headers = Headers( + Header.ContentRange.EndTotal("bytes", start.toInt, end.toInt, fileSize.toInt), + Header.AcceptRanges.Bytes, + ), + ) + } + } + + case _ => + ZIO.succeed( + Response( + status = Status.RequestedRangeNotSatisfiable, + headers = Headers(Header.ContentRange.RangeTotal("bytes", fileSize.toInt)), + ), + ) + } + } + def fromFile[R](makeFile: => File, charset: Charset = Charsets.Utf8)(implicit trace: Trace, ): Handler[R, Throwable, Request, Response] = { @@ -945,7 +987,7 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { ZIO.fail(new AccessDeniedException(file.getAbsolutePath)) } else { if (file.isFile) { - val pathName = file.toPath.toString + val pathName = file.toPath.toString (request.header(Header.Range) match { case Some(rangeHeader) => From dd924ca3285391fb6080759e3e29db361082948f Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Fri, 2 Jan 2026 17:52:58 -0300 Subject: [PATCH 07/11] implement RangedFileBody.asStream --- .../shared/src/main/scala/zio/http/Body.scala | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index f6b32a2f56..99e29bdad7 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -712,10 +712,54 @@ object Body { override def asChunk(implicit trace: Trace): Task[Chunk[Byte]] = ??? - override def asStream(implicit trace: Trace): ZStream[Any, Throwable, Byte] = { - println(s"[RangedFileBody] asStream called! file=${file.getName}, start=$start, end=$end, contentLength=$fileSize") - ZStream.fromIterable("TODO: implement response creation\n".getBytes) - } + override def asStream(implicit trace: Trace): ZStream[Any, Throwable, Byte] = + ZStream.unwrap { + ZIO.blocking { + ZIO.suspendSucceed { + try { + val totalBytesToRead = end - start + 1 + val raf = new java.io.RandomAccessFile(file, "r") + raf.seek(start) + + println(s"[RangedFileBody.asStream] Starting read - start=$start, end=$end, totalBytes=$totalBytesToRead") + + var bytesRead = 0L + + val read: Task[Option[Chunk[Byte]]] = + ZIO.suspendSucceed { + try { + val remaining = totalBytesToRead - bytesRead + if (remaining <= 0) { + Exit.none + } else { + val size = Math.min(chunkSize.toLong, remaining).toInt + val buffer = new Array[Byte](size) + val len = raf.read(buffer) + if (len > 0) { + bytesRead += len + println(s"[RangedFileBody.asStream] Read chunk: $len bytes, total read: $bytesRead/$totalBytesToRead") + Exit.succeed(Some(Chunk.fromArray(buffer.slice(0, len)))) + } else { + println(s"[RangedFileBody.asStream] Finished reading - total: $bytesRead bytes") + Exit.none + } + } + } catch { + case e: Throwable => Exit.fail(e) + } + } + + Exit.succeed { + ZStream + .unfoldChunkZIO(read)(_.map(_.map(_ -> read))) + .ensuring(ZIO.attempt(raf.close()).ignoreLogged) + } + } catch { + case e: Throwable => Exit.fail(e) + } + } + } + } override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) From 48d2361b829e5b8b372cc949971f5b30e481241e Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Sat, 3 Jan 2026 19:29:32 -0300 Subject: [PATCH 08/11] implement asArray --- zio-http/shared/src/main/scala/zio/http/Body.scala | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index 99e29bdad7..df8d4d24f0 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -704,7 +704,18 @@ object Body { override val contentType: Option[Body.ContentType] = None, ) extends Body { - override def asArray(implicit trace: Trace): Task[Array[Byte]] = ??? + override def asArray(implicit trace: Trace): Task[Array[Byte]] = + ZIO.attemptBlocking { + val raf = new java.io.RandomAccessFile(file, "r") + try { + raf.seek(start) + val buffer = new Array[Byte]((end - start + 1).toInt) + raf.readFully(buffer) + buffer + } finally { + raf.close() + } + } override def isComplete: Boolean = false From bc2d85bf1b01199f6f6ae4702398d9a2a2c81a65 Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Sat, 3 Jan 2026 19:55:04 -0300 Subject: [PATCH 09/11] Update test to pass the url to the handler --- zio-http/jvm/src/test/scala/zio/http/HandlerSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/HandlerSpec.scala b/zio-http/jvm/src/test/scala/zio/http/HandlerSpec.scala index 19e629bcf3..36214a0ac3 100644 --- a/zio-http/jvm/src/test/scala/zio/http/HandlerSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/HandlerSpec.scala @@ -393,7 +393,7 @@ object HandlerSpec extends ZIOHttpSpec with ExitAssertion { val tempFile = tempPath.toFile val http = Handler.fromFileZIO(ZIO.succeed(tempFile)) for { - r <- http.apply {} + r <- http.apply(Request.get(URL.root)) tempFile <- Body.fromFile(tempFile) } yield { assert(r.status)(equalTo(Status.Ok)) && From 5cf587af6bf777db26cfd85edd9196fe225095fc Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Sat, 3 Jan 2026 20:22:46 -0300 Subject: [PATCH 10/11] format code --- .../shared/src/main/scala/zio/http/Body.scala | 16 +++++++++------- .../shared/src/main/scala/zio/http/Handler.scala | 12 ++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index df8d4d24f0..ce0981e4c3 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -354,13 +354,13 @@ object Body { file: java.io.File, chunkSize: Int = 1024 * 4, start: Long = 0, - end: Option[Long] = None + end: Option[Long] = None, )(implicit trace: Trace): ZIO[Any, Nothing, Body] = { ZIO.attemptBlocking(file.length()).orDie.map { fileSize => val isRangeRequest = start > 0 || end.exists(_ < fileSize - 1) if (isRangeRequest) { - val endByte = end.getOrElse(fileSize - 1) + val endByte = end.getOrElse(fileSize - 1) val contentLength = endByte - start + 1 RangedFileBody(file, chunkSize, contentLength, start, endByte) } else { @@ -688,7 +688,7 @@ object Body { } } } - } + } override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) @@ -729,7 +729,7 @@ object Body { ZIO.suspendSucceed { try { val totalBytesToRead = end - start + 1 - val raf = new java.io.RandomAccessFile(file, "r") + val raf = new java.io.RandomAccessFile(file, "r") raf.seek(start) println(s"[RangedFileBody.asStream] Starting read - start=$start, end=$end, totalBytes=$totalBytesToRead") @@ -743,12 +743,14 @@ object Body { if (remaining <= 0) { Exit.none } else { - val size = Math.min(chunkSize.toLong, remaining).toInt + val size = Math.min(chunkSize.toLong, remaining).toInt val buffer = new Array[Byte](size) - val len = raf.read(buffer) + val len = raf.read(buffer) if (len > 0) { bytesRead += len - println(s"[RangedFileBody.asStream] Read chunk: $len bytes, total read: $bytesRead/$totalBytesToRead") + println( + s"[RangedFileBody.asStream] Read chunk: $len bytes, total read: $bytesRead/$totalBytesToRead", + ) Exit.succeed(Some(Chunk.fromArray(buffer.slice(0, len)))) } else { println(s"[RangedFileBody.asStream] Finished reading - total: $bytesRead bytes") diff --git a/zio-http/shared/src/main/scala/zio/http/Handler.scala b/zio-http/shared/src/main/scala/zio/http/Handler.scala index 3c06896ee2..c0e8720b3c 100644 --- a/zio-http/shared/src/main/scala/zio/http/Handler.scala +++ b/zio-http/shared/src/main/scala/zio/http/Handler.scala @@ -909,11 +909,11 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { } /** - * Set MIME type in the response headers. This is only relevant in - * case of RandomAccessFile transfers as browsers use the MIME type, - * not the file extension, to determine how to process a URL. - * {{{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}}} - */ + * Set MIME type in the response headers. This is only relevant in case of + * RandomAccessFile transfers as browsers use the MIME type, not the file + * extension, to determine how to process a URL. + * {{{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}}} + */ private def addMediaType( response: Response, pathName: String, @@ -923,7 +923,7 @@ object Handler extends HandlerPlatformSpecific with HandlerVersionSpecific { case Some(mediaType) => val charset0 = if (mediaType.mainType == "text" || !mediaType.binary) Some(charset) else None ZIO.succeed(response.addHeader(Header.ContentType(mediaType, charset = charset0))) - case None => ZIO.succeed(response) + case None => ZIO.succeed(response) } } From 8e0a246bd1501eda72f05218ddcd369e8b94ca2c Mon Sep 17 00:00:00 2001 From: Franco Ayala Date: Tue, 6 Jan 2026 18:27:49 -0300 Subject: [PATCH 11/11] disable mimacheck for Body.fromFile --- project/MimaSettings.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/project/MimaSettings.scala b/project/MimaSettings.scala index 182b86b5cd..af78cc9069 100644 --- a/project/MimaSettings.scala +++ b/project/MimaSettings.scala @@ -43,6 +43,7 @@ object MimaSettings { ProblemFilters.exclude[MissingClassProblem]("zio.http.netty.NettyHeaderEncoding"), ProblemFilters.exclude[MissingClassProblem]("zio.http.netty.NettyHeaderEncoding$"), exclude[Problem]("zio.http.template2.*"), + ProblemFilters.exclude[DirectMissingMethodProblem]("zio.http.Body.fromFile"), ), mimaFailOnProblem := failOnProblem, )