diff --git a/src/Mvc/Mvc.Core/src/ControllerBase.cs b/src/Mvc/Mvc.Core/src/ControllerBase.cs
index 547ed9fe1bf9d..6d79e1fbc05ab 100644
--- a/src/Mvc/Mvc.Core/src/ControllerBase.cs
+++ b/src/Mvc/Mvc.Core/src/ControllerBase.cs
@@ -1705,6 +1705,65 @@ public virtual PhysicalFileResult PhysicalFile(string physicalPath, string conte
EnableRangeProcessing = enableRangeProcessing,
};
}
+
+ ///
+ /// Writes the file directly to the response body with the specified (), and the
+ /// specified as the Content-Type.
+ ///
+ /// The callback that allows users to write directly to the response body.
+ /// The Content-Type of the file.
+ /// The created for the response.
+ [NonAction]
+ public virtual PushFileStreamResult File(Func streamWriterCallback, string contentType)
+ => File(streamWriterCallback, contentType, fileDownloadName: null);
+
+ ///
+ /// Writes the file directly to the response body with the specified (), the
+ /// specified as the Content-Type, and the specified as the suggested file name.
+ ///
+ /// The callback that allows users to write directly to the response body.
+ /// The Content-Type of the file.
+ /// The suggested file name.
+ /// The created for the response.
+ [NonAction]
+ public virtual PushFileStreamResult File(Func streamWriterCallback, string contentType, string? fileDownloadName)
+ => new PushFileStreamResult(streamWriterCallback, contentType) { FileDownloadName = fileDownloadName };
+
+ ///
+ /// Writes the file directly to the response body with the specified (), and the
+ /// specified as the Content-Type.
+ ///
+ /// The callback that allows users to write directly to the response body.
+ /// The Content-Type of the file.
+ /// The of when the file was last modified.
+ /// The associated with the file.
+ /// The created for the response.
+ [NonAction]
+ public virtual PushFileStreamResult File(Func streamWriterCallback, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
+ => new PushFileStreamResult(streamWriterCallback, contentType)
+ {
+ LastModified = lastModified,
+ EntityTag = entityTag,
+ };
+
+ ///
+ /// Writes the file directly to the response body with the specified (), the
+ /// specified as the Content-Type, and the specified as the suggested file name.
+ ///
+ /// The callback that allows users to write directly to the response body.
+ /// The Content-Type of the file.
+ /// The suggested file name.
+ /// The of when the file was last modified.
+ /// The associated with the file.
+ /// The created for the response.
+ [NonAction]
+ public virtual PushFileStreamResult File(Func streamWriterCallback, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
+ => new PushFileStreamResult(streamWriterCallback, contentType)
+ {
+ LastModified = lastModified,
+ EntityTag = entityTag,
+ FileDownloadName = fileDownloadName,
+ };
#endregion
///
diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
index 7a255af79f061..65fdb85906579 100644
--- a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
+++ b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
@@ -236,6 +236,7 @@ internal static void AddMvcCoreServices(IServiceCollection services)
services.TryAddSingleton, PhysicalFileResultExecutor>();
services.TryAddSingleton, VirtualFileResultExecutor>();
services.TryAddSingleton, FileStreamResultExecutor>();
+ services.TryAddSingleton, PushFileStreamResultExecutor>();
services.TryAddSingleton, FileContentResultExecutor>();
services.TryAddSingleton, RedirectResultExecutor>();
services.TryAddSingleton, LocalRedirectResultExecutor>();
diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/PushFileStreamResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/PushFileStreamResultExecutor.cs
new file mode 100644
index 0000000000000..1240b9897893d
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/PushFileStreamResultExecutor.cs
@@ -0,0 +1,84 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.Mvc.Infrastructure;
+
+///
+/// A for .
+///
+public partial class PushFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor
+{
+ ///
+ /// Initializes a new .
+ ///
+ /// The factory used to create loggers.
+ public PushFileStreamResultExecutor(ILoggerFactory loggerFactory)
+ : base(CreateLogger(loggerFactory))
+ {
+ }
+
+ ///
+ public virtual async Task ExecuteAsync(ActionContext context, PushFileStreamResult result)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(result);
+
+ Log.ExecutingFileResult(Logger, result);
+
+ var (range, rangeLength, serveBody) = SetHeadersAndLog(
+ context,
+ result,
+ fileLength: null,
+ result.EnableRangeProcessing,
+ result.LastModified,
+ result.EntityTag);
+
+ if (!serveBody)
+ {
+ return;
+ }
+
+ await WriteFileAsync(context, result, range, rangeLength);
+ }
+
+ ///
+ /// Write the contents of the PushFileStreamResult to the response body.
+ ///
+ /// The .
+ /// The PushFileStreamResult to write.
+ /// The .
+ /// The range length.
+ protected virtual async Task WriteFileAsync(
+ ActionContext context,
+ PushFileStreamResult result,
+ RangeItemHeaderValue? range,
+ long rangeLength)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(result);
+
+ Debug.Assert(range == null);
+ Debug.Assert(rangeLength == 0);
+
+ await result.StreamWriterCallback(context.HttpContext.Response.Body);
+ }
+
+ private static partial class Log
+ {
+ public static void ExecutingFileResult(ILogger logger, FileResult fileResult)
+ {
+ if (logger.IsEnabled(LogLevel.Information))
+ {
+ var fileResultType = fileResult.GetType().Name;
+ ExecutingFileResultWithNoFileName(logger, fileResultType, fileResult.FileDownloadName);
+ }
+ }
+
+ [LoggerMessage(1, LogLevel.Information, "Executing {FileResultType}, sending file with download name '{FileDownloadName}' ...", EventName = "ExecutingFileResultWithNoFileName", SkipEnabledCheck = true)]
+ private static partial void ExecutingFileResultWithNoFileName(ILogger logger, string fileResultType, string fileDownloadName);
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
index b4560b45280fa..8fc408d793c25 100644
--- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
@@ -1,6 +1,17 @@
#nullable enable
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Mvc.ObjectResult!
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary = null) -> Microsoft.AspNetCore.Mvc.ActionResult!
+Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor
+Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.PushFileStreamResultExecutor(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
+Microsoft.AspNetCore.Mvc.PushFileStreamResult
+Microsoft.AspNetCore.Mvc.PushFileStreamResult.PushFileStreamResult(System.Func! streamWriterCallback, string! contentType) -> void
+Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.get -> System.Func!
+Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.set -> void
+override Microsoft.AspNetCore.Mvc.PushFileStreamResult.ExecuteResultAsync(Microsoft.AspNetCore.Mvc.ActionContext! context) -> System.Threading.Tasks.Task!
+virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func! streamWriterCallback, string! contentType) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
+virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func! streamWriterCallback, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
+virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func! streamWriterCallback, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
+virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func! streamWriterCallback, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail, string? instance, int? statusCode, string? title, string? type) -> Microsoft.AspNetCore.Mvc.ObjectResult!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, System.Collections.Generic.IDictionary? extensions = null) -> Microsoft.AspNetCore.Mvc.ObjectResult!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail, string? instance, int? statusCode, string? title, string? type, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary) -> Microsoft.AspNetCore.Mvc.ActionResult!
@@ -9,3 +20,5 @@ Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory
Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.DefaultProblemDetailsFactory(Microsoft.Extensions.Options.IOptions! options, Microsoft.Extensions.Options.IOptions? problemDetailsOptions = null) -> void
override Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.CreateProblemDetails(Microsoft.AspNetCore.Http.HttpContext! httpContext, int? statusCode = null, string? title = null, string? type = null, string? detail = null, string? instance = null) -> Microsoft.AspNetCore.Mvc.ProblemDetails!
override Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.CreateValidationProblemDetails(Microsoft.AspNetCore.Http.HttpContext! httpContext, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary! modelStateDictionary, int? statusCode = null, string? title = null, string? type = null, string? detail = null, string? instance = null) -> Microsoft.AspNetCore.Mvc.ValidationProblemDetails!
+virtual Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.ExecuteAsync(Microsoft.AspNetCore.Mvc.ActionContext! context, Microsoft.AspNetCore.Mvc.PushFileStreamResult! result) -> System.Threading.Tasks.Task!
+virtual Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.WriteFileAsync(Microsoft.AspNetCore.Mvc.ActionContext! context, Microsoft.AspNetCore.Mvc.PushFileStreamResult! result, Microsoft.Net.Http.Headers.RangeItemHeaderValue? range, long rangeLength) -> System.Threading.Tasks.Task!
diff --git a/src/Mvc/Mvc.Core/src/PushFileStreamResult.cs b/src/Mvc/Mvc.Core/src/PushFileStreamResult.cs
new file mode 100644
index 0000000000000..beecfa3ea26c8
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/PushFileStreamResult.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+///
+/// A that on execution writes the file using the specified stream writer callback.
+///
+public class PushFileStreamResult : FileResult
+{
+ ///
+ /// The callback that writes the file to the provided stream.
+ ///
+ public Func StreamWriterCallback { get; set; }
+
+ ///
+ /// Creates a new instance with
+ /// the provided and the provided .
+ ///
+ /// The callback that writes the file to the provided stream.
+ /// The Content-Type header of the response.
+ public PushFileStreamResult(Func streamWriterCallback, string contentType) : base(contentType)
+ {
+ ArgumentNullException.ThrowIfNull(streamWriterCallback);
+
+ StreamWriterCallback = streamWriterCallback;
+ }
+
+ ///
+ public override Task ExecuteResultAsync(ActionContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var executor = context.HttpContext.RequestServices.GetRequiredService>();
+ return executor.ExecuteAsync(context, this);
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/PushFileStreamResultTest.cs b/src/Mvc/Mvc.Core/test/PushFileStreamResultTest.cs
new file mode 100644
index 0000000000000..6764430b83fa1
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/PushFileStreamResultTest.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+public class PushFileStreamResultTest : PushFileStreamResultTestBase
+{
+ protected override Task ExecuteAsync(
+ HttpContext httpContext,
+ Func streamWriterCallback,
+ string contentType,
+ DateTimeOffset? lastModified = null,
+ EntityTagHeaderValue entityTag = null)
+ {
+ httpContext.RequestServices = new ServiceCollection()
+ .AddSingleton()
+ .AddSingleton, PushFileStreamResultExecutor>()
+ .BuildServiceProvider();
+
+ var actionContext = new ActionContext(httpContext, new(), new());
+ var fileStreamResult = new PushFileStreamResult(streamWriterCallback, contentType)
+ {
+ LastModified = lastModified,
+ EntityTag = entityTag,
+ };
+
+ return fileStreamResult.ExecuteResultAsync(actionContext);
+ }
+
+ [Fact]
+ public void Constructor_SetsContentType()
+ {
+ // Arrange
+ var streamWriterCallback = (Stream _) => Task.CompletedTask;
+ var contentType = "text/plain; charset=us-ascii; p1=p1-value";
+ var expectedMediaType = contentType;
+
+ // Act
+ var result = new PushFileStreamResult(streamWriterCallback, contentType);
+
+ // Assert
+ Assert.Equal(expectedMediaType, result.ContentType);
+ }
+
+ [Fact]
+ public void Constructor_SetsLastModifiedAndEtag()
+ {
+ // Arrange
+ var streamWriterCallback = (Stream _) => Task.CompletedTask;
+ var contentType = "text/plain";
+ var expectedMediaType = contentType;
+ var lastModified = new DateTimeOffset();
+ var entityTag = new EntityTagHeaderValue("\"Etag\"");
+
+ // Act
+ var result = new PushFileStreamResult(streamWriterCallback, contentType)
+ {
+ LastModified = lastModified,
+ EntityTag = entityTag,
+ };
+
+ // Assert
+ Assert.Equal(lastModified, result.LastModified);
+ Assert.Equal(entityTag, result.EntityTag);
+ Assert.Equal(expectedMediaType, result.ContentType);
+ }
+}
diff --git a/src/Shared/ResultsTests/PushFileStreamResultTestBase.cs b/src/Shared/ResultsTests/PushFileStreamResultTestBase.cs
new file mode 100644
index 0000000000000..b85b67b3bd7da
--- /dev/null
+++ b/src/Shared/ResultsTests/PushFileStreamResultTestBase.cs
@@ -0,0 +1,140 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.Internal;
+
+public abstract class PushFileStreamResultTestBase
+{
+ protected abstract Task ExecuteAsync(
+ HttpContext httpContext,
+ Func streamWriterCallback,
+ string contentType,
+ DateTimeOffset? lastModified = null,
+ EntityTagHeaderValue entityTag = null);
+
+ [Fact]
+ public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored()
+ {
+ // Arrange
+ var contentType = "text/plain";
+ var lastModified = DateTimeOffset.MinValue;
+ var entityTag = new EntityTagHeaderValue("\"Etag\"");
+ var byteArray = Encoding.ASCII.GetBytes("Hello World");
+ var streamWriterCallback = (Stream stream) => stream.WriteAsync(byteArray).AsTask();
+
+ var httpContext = GetHttpContext();
+ var requestHeaders = httpContext.Request.GetTypedHeaders();
+ requestHeaders.IfMatch = new[]
+ {
+ new EntityTagHeaderValue("\"Etag\""),
+ };
+ requestHeaders.Range = new RangeHeaderValue(0, 4);
+ requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue);
+ httpContext.Request.Method = HttpMethods.Get;
+ httpContext.Response.Body = new MemoryStream();
+
+ // Act
+ await ExecuteAsync(httpContext, streamWriterCallback, contentType, lastModified, entityTag);
+
+ // Assert
+ var httpResponse = httpContext.Response;
+ httpResponse.Body.Seek(0, SeekOrigin.Begin);
+ var streamReader = new StreamReader(httpResponse.Body);
+ var body = streamReader.ReadToEndAsync().Result;
+ Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode);
+ Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified);
+ Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag);
+ Assert.Equal("Hello World", body);
+ }
+
+ [Fact]
+ public async Task WriteFileAsync_CopiesProvidedData_ToOutputStream()
+ {
+ // Arrange
+ // Generate an array of bytes with a predictable pattern
+ // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13
+ var originalBytes = Enumerable.Range(0, 0x1234)
+ .Select(b => (byte)(b % 20)).ToArray();
+
+ var streamWriterCallback = (Stream stream) => stream.WriteAsync(originalBytes).AsTask();
+
+ var httpContext = GetHttpContext();
+ var outStream = new MemoryStream();
+ httpContext.Response.Body = outStream;
+
+ // Act
+ await ExecuteAsync(httpContext, streamWriterCallback, "text/plain");
+
+ // Assert
+ var outBytes = outStream.ToArray();
+ Assert.True(originalBytes.SequenceEqual(outBytes));
+ }
+
+ [Fact]
+ public async Task SetsSuppliedContentTypeAndEncoding()
+ {
+ // Arrange
+ var expectedContentType = "text/foo; charset=us-ascii";
+ // Generate an array of bytes with a predictable pattern
+ // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13
+ var originalBytes = Enumerable.Range(0, 0x1234)
+ .Select(b => (byte)(b % 20)).ToArray();
+
+ var streamWriterCallback = (Stream stream) => stream.WriteAsync(originalBytes).AsTask();
+
+ var httpContext = GetHttpContext();
+ var outStream = new MemoryStream();
+ httpContext.Response.Body = outStream;
+
+ // Act
+ await ExecuteAsync(httpContext, streamWriterCallback, expectedContentType);
+
+ // Assert
+ var outBytes = outStream.ToArray();
+ Assert.True(originalBytes.SequenceEqual(outBytes));
+ Assert.Equal(expectedContentType, httpContext.Response.ContentType);
+ }
+
+ [Fact]
+ public async Task HeadRequest_DoesNotWriteToBody()
+ {
+ // Arrange
+ var streamWriterCallback = (Stream stream) => stream.WriteAsync("Hello, World!"u8.ToArray()).AsTask();
+
+ var httpContext = GetHttpContext();
+ httpContext.Request.Method = "HEAD";
+ var outStream = new MemoryStream();
+ httpContext.Response.Body = outStream;
+
+ // Act
+ await ExecuteAsync(httpContext, streamWriterCallback, "text/plain");
+
+ // Assert
+ Assert.Equal(200, httpContext.Response.StatusCode);
+ Assert.Equal(0, httpContext.Response.Body.Length);
+ }
+
+ private static IServiceCollection CreateServices()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
+ services.AddSingleton(NullLoggerFactory.Instance);
+ return services;
+ }
+
+ private static HttpContext GetHttpContext()
+ {
+ var services = CreateServices();
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.RequestServices = services.BuildServiceProvider();
+ return httpContext;
+ }
+}