Transform the response body on every page
Implement IResponseProcessor to rewrite the final response body as a string — inject HTML before </body>, log an outgoing payload, or append a non-HTML footer.
To transform the final response body on every rendered page, implement IResponseProcessor. The processor receives the full body as a string and returns the replacement — use it to insert a pre-serialized HTML fragment before </body>, log an outgoing payload, or append a non-HTML footer. When the work is DOM-shaped (anchor rewrites, attribute additions, element injection at a CSS selector), implement IHtmlResponseRewriter instead so every rewriter shares one AngleSharp parse. See Rewrite HTML attributes after parsing.
The recipe references examples/ExtensibilityLabExample/FeedbackWidgetProcessor.cs, which injects a "Was this helpful?" aside before </body> against a bare AddPennington host.
Before you begin
- An existing Pennington site (see Create your first Pennington site if not).
- The response pipeline buffers the full response body before the processor runs. This is fine for HTML pages but unsuitable for large binary streams — gate those out in
ShouldProcess.
Write the processor
Implement Pennington.Infrastructure.IResponseProcessor as a sealed class. Two rules carry the page:
ShouldProcessruns before the body is buffered. Returningfalseskips body capture entirely, so this is where filtering by status code, content type, or request path belongs. The example accepts only 2xx HTML responses, letting static assets, JSON endpoints, and redirects pass through untouched.ProcessAsyncreceives the full captured body as a string and returns the replacement. The example locates the last</body>withLastIndexOfand inserts the widget HTML there, falling back to append-at-end when the tag is absent so content still reaches the browser.
namespace ExtensibilityLabExample;
using System.Text;
using Microsoft.AspNetCore.Http;
using Pennington.Infrastructure;
/// <summary>
/// Implements <see cref="IResponseProcessor"/>. Injects a
/// "Was this helpful?" footer before the closing <c></body></c>
/// tag of every rendered HTML page.
/// <para>
/// Runs at <see cref="Order"/> 500 — after the xref/locale/base-URL HTML
/// rewriting processor (<c>HtmlResponseRewritingProcessor</c>) so the
/// injected HTML is not subject to any further pipeline passes in this
/// app, and well before the live-reload and diagnostic-overlay
/// processors at 1000+.
/// </para>
/// <para>
/// <see cref="ShouldProcess"/> gates on content type: text/html only,
/// and only for 2xx responses. Static assets and API JSON skip through.
/// </para>
/// <para>
/// Backs how-to 2.3.40 <c>/how-to/extensibility/response-processor</c>.
/// </para>
/// </summary>
public sealed class FeedbackWidgetProcessor : IResponseProcessor
{
private const string WidgetHtml = """
<aside class="feedback-widget" data-extensibility-lab="feedback-widget">
<p><strong>Was this helpful?</strong>
<button type="button" data-feedback="yes">Yes</button>
<button type="button" data-feedback="no">No</button>
</p>
</aside>
""";
public int Order => 500;
public bool ShouldProcess(HttpContext context)
{
if (context.Response.StatusCode is < 200 or >= 300)
{
return false;
}
var contentType = context.Response.ContentType;
return contentType is not null
&& contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
}
public Task<string> ProcessAsync(string responseBody, HttpContext context)
{
if (string.IsNullOrEmpty(responseBody))
{
return Task.FromResult(responseBody);
}
var closeBodyIndex = responseBody.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
if (closeBodyIndex < 0)
{
// No </body> — append at end. Still visible, still verifiable.
return Task.FromResult(responseBody + WidgetHtml);
}
var sb = new StringBuilder(responseBody.Length + WidgetHtml.Length);
sb.Append(responseBody, 0, closeBodyIndex);
sb.Append(WidgetHtml);
sb.Append(responseBody, closeBodyIndex, responseBody.Length - closeBodyIndex);
return Task.FromResult(sb.ToString());
}
}
Pick an Order value
Slot into the Order sequence so the processor sees the HTML state it expects. Anything below 10 would see un-resolved <xref:...> placeholders that HtmlResponseRewritingProcessor expands. The example uses 500 so the widget is inserted after every built-in pass has run. For the full table of shipped Order values, see Pennington.Infrastructure.IResponseProcessor.
Register the processor
Every registered IResponseProcessor is picked up and ordered by its Order value, so a single registration is the entire wiring step. Use the lifetime that matches your dependencies — AddSingleton for stateless processors, AddTransient (or AddFileWatched) when the processor captures file-watched state.
builder.Services.AddSingleton<IResponseProcessor, FeedbackWidgetProcessor>();
Result
Every text/html response carries the widget aside immediately before its closing </body> tag:
<aside class="feedback-widget" data-extensibility-lab="feedback-widget">
<p><strong>Was this helpful?</strong>
<button type="button" data-feedback="yes">Yes</button>
<button type="button" data-feedback="no">No</button>
</p>
</aside>
</body>
</html>
Non-HTML endpoints (/styles.css, /sitemap.xml) are unmodified because ShouldProcess returns false for them.
Verify
- Run
dotnet run --project examples/ExtensibilityLabExampleand visit/. The rendered HTML contains<aside class="feedback-widget" data-extensibility-lab="feedback-widget">immediately before</body>; fetch/styles.cssand the aside is absent. - Static build:
dotnet run --project examples/ExtensibilityLabExample -- build output— grepoutput/index.htmlfordata-extensibility-lab="feedback-widget"to confirm the processor runs during publish as well as dev.
Related
- Reference: Response processing interfaces
- Background: The response-processing pipeline
- Related how-to: Write an HTML rewriter