Skip to content

Commit b28e693

Browse files
committed
Updates
1 parent 7c05159 commit b28e693

6 files changed

Lines changed: 381 additions & 7 deletions

File tree

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ is built on the one below and they compose in the same app.
7979

8080
| | |
8181
| --- | --- |
82-
| **Core** | Routing with constraints and runtime-mutable routes, ASP.NET-shaped middleware, a real `IServiceScope` per request, results in both `Results.*` and `IActionResult` spellings, RFC 9457 problem details and an exception-handler chain |
82+
| **Core** | Routing with constraints and runtime-mutable routes, ASP.NET-shaped middleware that can read and rewrite both bodies of an exchange, a real `IServiceScope` per request, results in both `Results.*` and `IActionResult` spellings, RFC 9457 problem details and an exception-handler chain |
8383
| **Formats** | Content negotiation in both directions — responses chosen from `Accept`, request bodies from `Content-Type`. JSON out of the box; XML, MessagePack and protobuf are one line each, and a format of your own is an `IOutputFormatter`/`IInputFormatter` pair. XML and MessagePack need no dependency and no attributes on your DTOs: they read the same `JsonTypeInfo` the JSON path reads, which is what keeps them AOT-clean where `XmlSerializer` cannot be |
8484
| **Protocols** | HTTP/1.1, HTTP/2 (own HPACK), HTTP/3 (own QPACK), WebSockets, Server-Sent Events, trailing headers on all three versions. Never guessed — ALPN over TLS, connection preface over cleartext |
8585
| **Content** | Static files from disk *or* embedded resources, a published Blazor WebAssembly app, streaming multipart uploads, downloads with byte ranges and conditional GETs, a file browser over a directory, and brotli/gzip/deflate compression |

skills/shiny-httpserver/SKILL.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ triggers:
1515
- MapPost
1616
- OnRequest
1717
- IHttpMiddleware
18+
- IResponseBodyControl
19+
- request logging
20+
- traffic recorder
21+
- capture request body
22+
- capture response body
23+
- read body twice
1824
- RequestDelegate
1925
- HttpContext
2026
- RouteAttribute
@@ -494,8 +500,34 @@ app.UseSessions();
494500
app.UseStaticFiles("./wwwroot");
495501
```
496502

503+
### Seeing the bodies (request logging, traffic recording, audit)
504+
505+
Both directions have a seam; use them rather than trying to read a body twice off the wire.
506+
507+
```csharp
508+
// inbound: read once, hand the handler a rewound copy (also drops any BodyReader handed out)
509+
var buffered = new MemoryStream();
510+
await ctx.Request.Body.CopyToAsync(buffered, ctx.RequestAborted);
511+
buffered.Position = 0;
512+
ctx.Request.Body = buffered;
513+
514+
// outbound: wrap the control the response is bound to, then bind the wrapper
515+
var tee = new TeeBodyControl(ctx.Response.BodyControl, capture); // : IResponseBodyControl
516+
ctx.Response.Bind(tee);
517+
try { await next(ctx); }
518+
finally { await tee.FlushAsync(); } // see gotcha below
519+
```
520+
521+
A wrapper must forward `StartAsync`/`CompleteAsync` to the control it wrapped, and should build its
522+
`Writer` over its own `Stream` (not over `inner.Writer`) so both write paths meet in one place. This
523+
is the same seam `UseResponseCompression()` inserts itself through. On a device, decide from the
524+
content type whether a body is worth keeping at all and cap what you store.
525+
497526
**Critical gotchas:**
498527

528+
- **Flush what a body-control wrapper buffered before the pipeline unwinds.** The connection
529+
completes its own producer, not whatever the response ended up bound to, so bytes left in a
530+
wrapper's `PipeWriter` never reach the wire.
499531
- The pipeline is composed **once**, at first serve. Registering middleware after the server starts
500532
throws; `RestartAsync` does not recompose it. Routes *can* change at any time.
501533
- Headers flush on the first body write. To add a header around a handler, use

src/Shiny.Net.HttpServer/Core/HttpRequest.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,29 @@ public string? ContentType
6464
public bool IsChunked { get; internal set; }
6565

6666
/// <summary>
67-
/// The request body as a stream. Read-only and forward-only — suitable for streaming a large
68-
/// upload straight to disk without buffering. Never null; an empty stream when there is no body.
67+
/// The request body as a stream. As it arrives off the connection it is read-only and
68+
/// forward-only — suitable for streaming a large upload straight to disk without buffering.
69+
/// Never null; an empty stream when there is no body.
70+
/// <para>
71+
/// Settable so a middleware can put something else in front of the handler: the usual reason is
72+
/// to read the body once and hand a rewound <see cref="MemoryStream"/> on, which is what makes
73+
/// a body readable twice — by a recorder or a logger, and then by the handler. Assigning also
74+
/// discards any <see cref="BodyReader"/> already handed out, so the two never disagree about
75+
/// where the body starts.
76+
/// </para>
6977
/// </summary>
7078
public Stream Body
7179
{
7280
get => this.body ??= EmptyReadStream.Instance;
73-
internal set => this.body = value;
81+
set
82+
{
83+
ArgumentNullException.ThrowIfNull(value);
84+
this.body = value;
85+
86+
// a reader built over the old stream would keep reading the old stream, and its
87+
// buffered-but-unconsumed bytes would be lost either way
88+
this.bodyReader = null;
89+
}
7490
}
7591

7692
/// <summary>

src/Shiny.Net.HttpServer/Core/HttpResponse.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,24 @@ public void Redirect(string location, bool permanent = false, bool preserveMetho
210210
this.Headers.Set(HeaderNames.Location, location);
211211
}
212212

213-
internal void Bind(IResponseBodyControl bodyControl) => this.control = bodyControl;
213+
/// <summary>
214+
/// Puts <paramref name="bodyControl"/> in charge of framing this response. A middleware that
215+
/// wants to see or transform the body wraps <see cref="BodyControl"/> and binds the wrapper —
216+
/// do it before calling the next delegate, and make sure whatever the wrapper buffered is
217+
/// flushed afterwards, because the connection completes its own producer rather than whatever
218+
/// the response ended up bound to.
219+
/// </summary>
220+
public void Bind(IResponseBodyControl bodyControl)
221+
{
222+
ArgumentNullException.ThrowIfNull(bodyControl);
223+
this.control = bodyControl;
224+
}
214225

215226
/// <summary>
216227
/// The control currently framing this response, so a middleware can wrap it — which is how
217228
/// response compression inserts itself without every writer knowing about it.
218229
/// </summary>
219-
internal IResponseBodyControl BodyControl => this.control;
230+
public IResponseBodyControl BodyControl => this.control;
220231

221232
internal async ValueTask InvokeOnStartingAsync()
222233
{

src/Shiny.Net.HttpServer/Core/IResponseBodyControl.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@ namespace Shiny.Net.HttpServer;
66
/// The seam between <see cref="HttpResponse"/> and whatever is actually framing bytes onto the
77
/// wire. Implemented per protocol version, which is what lets HTTP/2 slot in later without
88
/// touching the public response surface.
9+
/// <para>
10+
/// Public so a middleware can sit in the middle of it: take the control the response is currently
11+
/// bound to, wrap it, and <see cref="HttpResponse.Bind"/> the wrapper. That is how response
12+
/// compression inserts itself without every writer knowing about it, and it is the same seam a
13+
/// recorder or a logger needs to see the bytes of a body it did not write. Wrappers must forward
14+
/// <see cref="StartAsync"/> and <see cref="CompleteAsync"/> to the control they wrapped, and must
15+
/// flush anything they buffered before the connection completes.
16+
/// </para>
917
/// </summary>
10-
interface IResponseBodyControl
18+
public interface IResponseBodyControl
1119
{
1220
bool HasStarted { get; }
1321
Stream Stream { get; }

0 commit comments

Comments
 (0)