| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| Suave.3.7.0.nupkg | 2026-10-15 | 850.7 kB | |
| Suave.3.7.0.symbols.nupkg | 2026-10-15 | 1.1 MB | |
| Suave.DotLiquid.3.7.0.nupkg | 2026-10-15 | 25.5 kB | |
| Suave.DotLiquid.3.7.0.symbols.nupkg | 2026-10-15 | 28.8 kB | |
| Suave.Json.3.7.0.nupkg | 2026-10-15 | 16.1 kB | |
| Suave.Json.3.7.0.symbols.nupkg | 2026-10-15 | 17.3 kB | |
| README.md | 2026-10-15 | 14.2 kB | |
| v3.7.0 source code.tar.gz | 2026-10-15 | 1.8 MB | |
| v3.7.0 source code.zip | 2026-10-15 | 2.0 MB | |
| Totals: 9 Items | 5.8 MB | 0 | |
- Fix HTTP/2 responses whose content is a
SocketTaskbeing sent with status 200 and an empty body:Files.file,Files.browse,Stream.okStream,Stream.okStreamChunked,TransferEncoding.chunked,EventSource.handShakeand custom writers now work over HTTP/2 (TLS with ALPNh2, h2c upgrade and h2c prior knowledge). Since HTTPS bindings advertiseh2first, browsers received empty static files and event streams from 3.6.1 and earlier. The HTTP/2 writer gives the task a per-stream connection: header lines the task writes join the HEADERS frame, HTTP/1.1 chunk framing is removed, and the body goes out as flow-controlled DATA frames as it is written; once the client resets the stream, writes return an error - HTTP/2 responses no longer carry connection-specific headers (
Connection,Keep-Alive,Proxy-Connection,Transfer-Encoding,Upgrade, RFC 9113 §8.2.2);Transfer-Encoding: chunkedmade clients fail the stream with PROTOCOL_ERROR (curl exit 92) - New
SuaveConfig.http2Enabled(defaulttrue, alsowithHttp2EnabledandHttpRuntime.http2Enabled): whenfalse, HTTPS bindings offer onlyhttp/1.1via ALPN,Upgrade: h2cis ignored and the HTTP/2 prior-knowledge preface is refused with 400. Code that buildsSuaveConfigorHttpRuntimewith a full record expression must add the field;{ defaultConfig with ... }is unaffected - The h2c
101 Switching Protocolsresponse, and any other 1xx response, no longer carries the defaultContent-Type: text/html - HTTP/2 response header blocks are HPACK-encoded under the connection's write lock, so concurrent streams cannot write their HEADERS frames in a different order from the encoder's dynamic table updates
- Behaviour change:**
defaultErrorHandlerno longer returns the exception message and stack trace to "local" clients; every client gets a plain500 Internal Server Error. The local check used the connection address, or anX-Forwarded-For/X-Real-IPheader the client could set to127.0.0.1; behind a reverse proxy on the same host (nginx -> 127.0.0.1) every remote client received stack traces. For development, opt in with the newdevelopmentErrorHandler({ defaultConfig with errorHandler = developmentErrorHandler }), which shows the HTML-encoded details to every client - HTTP/2 requests now carry the binding they arrived on in
request.binding; it was the defaulthttp://0.0.0.0:8080, sorequest.binding.scheme.securewasfalseandrequest.urlwas wrong for HTTPS clients that negotiated HTTP/2 (a redirect-to-HTTPS check looped for them) - Docs: the deployment guide uses .NET 10 and the
runtimeimage with a non-root user and a curl-free health check, shows graceful shutdown on SIGTERM, fixes the nginx WebSocket/keep-alive setup, usesX509CertificateLoader, and replaces the ASP.NET Core Module IIS example with HttpPlatformHandler; the HTTPS recipe's redirect keeps the query string and port Validation.Stringvalidators (minLength,maxLength,exactLength,lengthBetween,matches,email,alphanumeric,alpha,numeric,contains,startsWith,endsWith) returnInvalidwith "Value is required" for anullinput instead of throwingNullReferenceException, e.g. for a field System.Text.Json leftnull;notEmptyis unchangedValidationWebParts.validationErrorsToJsonwrites its output withUtf8JsonWriter, so fields and messages containing",\or control characters produce valid JSON; the shape is unchanged, the output is now compact (no spaces), and characters such as',<and non-ASCII are written as\uXXXXescapes- Breaking: the
<++>operator now preserves left-to-right order:a <++> breportsa's errors beforeb's and returnsa's value when both pass (previously the reverse).a |> andAlso bis unchanged HttpRequest.queryreturns(key, None)for a query key without a value (?flag), as documented, instead of(key, Some "");?key=is still(key, Some "").queryFlag "flag"is nowtruefor?flag, andqueryParam "flag"returnsChoice2Of2for it. Form parsing (form,formData) is unchangedpathScan,pathScanCiandSscanf.sscanfno longer wrap integers that are out of range for the target type:/items/99999999999againstpathScan "/items/%d"with aninthandler now declines instead of passing1215752191; the same applies toint16,uint16anduint32(and%x/%X/%ovalues that do not fit)- Behaviour change:**
CORS.defaultCORSConfigno longer allows credentials (allowCookies = false). It used to reflect anyOrigintogether withAccess-Control-Allow-Credentials: true, which lets any web site make authenticated requests as your users. To allow credentials, list the trusted origins too:{ defaultCORSConfig with allowedUris = InclusiveOption.Some [ "https://app.example.com" ]; allowCookies = true } corsaddsVary: Origin(merged into an existingVary) so caches do not serve one origin's CORS response to anothercorsno longer sendsAccess-Control-Allow-Methods: GET,for a single allowed method; methods are joined asGET, POST- Add
CORS.withCORS config handler, which answers preflight requests with 204 itself and runshandleronly for actual requests, andCORS.corsPreflight config, which answers preflights and declines other requests (choose [ corsPreflight cfg; cors cfg >=> app ]).cors cfg >=> handlerstill lets the handler replace the preflight's 204, as documented - Behaviour change:**
RateLimit.defaultKeyExtractorkeys by the connection's remote address and ignoresX-Forwarded-For/X-Real-IP, which a client could change on every request to avoid the limit. Behind a reverse proxy use the newRateLimit.trustedProxyKeyExtractor [ proxyAddress ], which readsX-Forwarded-Foronly on connections from the listed proxies.userKeyExtractorandheaderKeyExtractorfall back to the new default - Each
rateLimitWebPart keeps its own counters. All limiters used to share one store keyed only by the extracted key, so two limiters keyed by IP counted together, and limiters with different strategies denied every request after the first (e.g. aFixedWindowlimiter returned 429 after aSlidingWindowone had seen the same IP). Idle entries are still cleaned up.getStatsreturns the first limiter that has an entry for the key - Add
RateLimit.defaultRateLimitConfig;RateLimit.defaultConfigis obsolete because afteropen Suave.RateLimitit shadows the server'sdefaultConfig. The obsolete alias still shadows it until it is removed; useWeb.defaultConfigmeanwhile securityHeadersreplaces headers of the same name already on the response instead of adding a second copy (e.g. twoCache-Controlheaders)- Security header presets send
X-XSS-Protection: 0instead of1; mode=block.strictConfigno longer sends the deprecatedblock-all-mixed-contentor HSTSpreload(opt in withwithHSTS 31536000 true true), and sendsCache-Control: no-storeinstead ofno-store, no-cache Files.sendFile(and sofile,browse,browseHome,browseFile) treatsRangebyte positions as inclusive:bytes=2-5is now answered withContent-Range: bytes 2-5/10and four bytes instead of three. Open-ended (bytes=5-) and suffix (bytes=-3) ranges are supported, a range past the end is clamped, a range starting at or after the end is answered with416andContent-Range: bytes */<length>, and malformed, multi-range or non-bytesheaders get the whole file. Partial responses are never compressedFiles.sendFileno longer sendsContent-Rangeon full200responses, compressed or not; it is only sent with206and416- Upload temp files written for
request.filesare deleted once the response has been written (or the connection ends) instead of being left in the temp folder. Move or copy an upload during the request to keep it; streaming it back withFiles.sendFilestill works. Files written by a customfilePartSinkare not touched Files.browse,browseHome,browseFile,browseFileHomeanddirdecline a request whose path escapes the root (such as/../secret.txt) instead of throwing, so it gets the application's 404 rather than a 500. NewFiles.tryResolvePath;resolvePathstill throws- When the compressed-files folder cannot be written to (the default,
AppContext.BaseDirectory, is read-only underdotnet fsiand in read-only containers), compressible files are served uncompressed instead of the connection being closed without a response; the failure is reported once per folder throughGlobals.writeMessage - Docs: the file upload recipe and the file handling guide no longer build the destination path from the client-supplied file name, which allowed writing outside the upload folder with
filename=../../x - The Suave.DotLiquid package declares
DotLiquid >= 2.3.197; 3.6.1 and earlier declaredDotLiquidwith no lower bound (0.0), so NuGet picked DotLiquid 0.0.0 and the first render failed withMissingMethodException(Template.set_NamingConvention). Workaround for those versions: referenceDotLiquid2.3.197 explicitly. The DotLiquid docs now use| escapein their examples (DotLiquid does not HTML-escape output) and cover the response charset and template field naming open Suaveno longer shadows F#'sResult.Ok/Result.Error: the parser'sScanResulttype (NeedMore | Found | Error) leaked itsErrorcase into theSuavenamespace, so bareError "x"or| Error e ->in user code resolved to it.ScanResultis now internal and requires qualified access, andHttpReader.scanMarkerandHttpReader.skip, which returned it, are now internal too. Code that matched onScanResultor called those members must useHttpReader.readUntilPatterninstead- Fix multipart/form-data bodies whose
multipart/mixedpart is followed by another part: the parser did not wait for the rest of the outer boundary line to be skipped, so when that data had not arrived yet the request hung or lost the parts after it - Security:
EventSource.dataandEventSource.sendwrite onedata:line per line of the data (split on CRLF, LF and CR), so line breaks in a message can no longer end the event and inject fields or extra events (e.g.Message.create "1" "hello\n\nevent: admin\ndata: x"produced a secondadminevent).esId,eventType,commentand the messageid/typehave CR and LF removed.sendnow writes the whole message in one write - Write failures are no longer swallowed, so streaming loops stop when the client disconnects (an
EventSource.sendloop used to keep running forever):Connection.asyncWriteBytes,Connection.writeChunk,EventSource.send/data/comment/dispatch/…,transferStreamandtransferStreamChunkedraise the newSuave.Sockets.SocketWriteException(anIOExceptioncarrying the socketError) when a write fails, which ends the response and closes the connection. New non-throwing variants returnTask<Result<unit, Error>>:Connection.tryWriteBytes,Connection.tryFlush,Connection.tryWriteChunk,Connection.tryWriteLastChunk,EventSource.trySendandEventSource.tryComment.TcpTransport/SslTransport.writereturnErrorinstead of throwing when the socket is already shut down Connection.writeChunkwrites the raw bytes: it converted them to a UTF-8 string first, so non-UTF-8 data was corrupted and no longer matched the declared chunk length (FF FE 00was declared as 3 bytes and sent as 7). NewConnection.writeLastChunk/tryWriteLastChunkwrite the terminating0\r\n\r\nchunk (aswriteChunk [||]does)TransferEncoding.chunkedanswers 200 when the response still has the initial 404 status of a fresh response (TransferEncoding.chunked writeralone used to answer 404); any other status set before it is kept, and a chunked 404 can be sent withTransferEncoding.chunked writer >=> Writers.setStatus HTTP_404. The response headers are now flushed before the writer runs, so writers can write straight toconn.transport- Breaking:
EventSource.handShakekeeps the headers already set on the response (it replaced them all) and no longer sendsAccess-Control-Allow-Origin: *. To allow cross-originEventSourceclients, compose it withWriters.setHeader "Access-Control-Allow-Origin" "*"(or your origin) orCORS.cors.Content-Typeis alwaystext/event-stream; charset=utf-8;Cache-Control: no-cacheandX-Accel-Buffering: noare added unless already set - WebSocket frames are limited to
SuaveConfig.webSocketMaxFrameSizebytes (new field, default 16 MiB; also onHttpRuntime). A frame announcing a larger payload is answered with a Close frame carrying code 1009 and the connection is dropped before any buffer is allocated or thereadIntoByteSegmentlength function is called; previously a client could announce up to 2 GB per frame andread ()allocated it up front. Override per socket withWebSocket.maxFrameSize. Code that buildsSuaveConfigorHttpRuntimewithout{ defaultConfig with ... }/{ HttpRuntime.empty with ... }must set the new field - An exception thrown by a WebSocket handler (
handShake,handShakeTask,handShakeWithSubprotocol) is reported throughGlobals.writeMessageand answered with a Close frame carrying code 1011 (newCloseCode.CLOSE_INTERNAL_ERROR); previously it escaped and Suave wrote anHTTP/1.1 500response, with the exception text for local clients, onto the upgraded connection handShakeno longer writes anHTTP/1.1 404 Not Foundresponse onto the connection when the WebSocket handler finishes, and a refused handshake now gets its400 Bad Request/405 Method Not Allowedinstead of404- Close frames sent by Suave carry a two-byte status code as RFC 6455 requires (they sent four bytes, so clients read status 0)
101 Switching Protocols(and other 1xx) responses no longer get a defaultContent-Type: text/htmlheader- WebSocket docs and recipes check the
Originheader beforehandShake(cross-site WebSocket hijacking), answer Ping with Pong and Close with Close, and the broadcast recipe uses aMailboxProcessorhub that drops clients whose send fails