Repository navigation
Releases: PerfectlySoft/Perfect-NIO
Release list
Perfect-NIO 0.1.0
The first tagged release of Perfect-NIO: the Swift 6 rewrite of Perfect's HTTP(S) server on SwiftNIO, with typed route pipelines, async handlers, WebSockets, multi-tenant TLS, an optional admin console and Perfect-CRUD route helpers. These are initial-release notes, not a migration guide from an earlier tag (there is none). Apps that tracked main and the older legacy branch should read "Breaking and behaviour changes" and "Upgrade notes".
0.x: the API may change between minor versions. Perfect-NIO stays on 0.x until its API settles. A 0.x minor release (0.1 → 0.2) may contain breaking changes, and its release notes will list them. from: "0.1.0" accepts any 0.x release; .upToNextMinor(from: "0.1.0") takes only 0.1.x fixes.
Requirements
- Swift 6.2 or later (
// swift-tools-version: 6.2), Swift 6 language mode. - macOS 12+ or Linux. No iOS, tvOS or watchOS.
- Linux:
pkg-configandzlib1g-devfor theCZlibsystem library. macOS needs nothing extra. PerfectNIOCRUDuses Perfect-CRUD 3.0.1 or newer. Perfect-MySQL 4.0.0 is used only by the package's own MySQL tests; building the library products needs no MySQL client library.
.package(url: "https://github.com/PerfectlySoft/Perfect-NIO.git", from: "0.1.0")This release adds a GitHub Actions workflow for every push and pull request: Linux (Swift 6.2 and 6.4, debug and release) against a MySQL 8.4 service with the live tests required (MYSQL_REQUIRE_LIVE=1), and macOS (Xcode 26.0.1 and 26.6, debug and release). Warnings are errors everywhere, tests included, and every job runs with TZ=America/Denver.
Highlights
- Swift 6 rewrite (#13). Route closures are
asyncand@Sendable.Server(routes:port:…)replacesbind().listen().wait():run()serves until the task is cancelled,withServer { port in … }gives a scoped server for tests. The response body is pulled fromHTTPOutput.nextChunk(allocator:). Logging uses apple/swift-log. - WebSockets (#13, #18, #19).
webSocket(protocol:options:_:)declares an endpoint; its callback runs through the normal route pipeline at handshake time and returns an asyncWebSocketHandler. Only routes declared withwebSocket(...)upgrade, a handshake to any other path is served as ordinary HTTP (RFC 6455 §4.2.2), and the declared subprotocol is now selected in the 101 when the client offers it. - Multi-tenant TLS (#13).
TLSContextManagerpicks a certificate per SNI hostname and can be updated live;CertificateWatcherreloads renewed certificates;ACMEChallengeResponderanswers HTTP-01 challenges;Server.httpRedirect()builds an HTTP→HTTPS redirect server. - Graceful restarts (#13).
alwaysReusePortlets a replacement process bind the same port before the old one stops;reusePortCountopens severalSO_REUSEPORTsockets in one process. - PerfectAdminConsole (#13). An opt-in admin server bound to 127.0.0.1: status, TLS domains, logs, routes, metrics, datasources and actions, with bearer-token auth, CSRF protection and a self-contained web dashboard.
- PerfectNIOCRUD (#13).
.db(_:_:)/.table(_:_:_:)route operators for Perfect-CRUD, plus pooled.db(pool:_:)/.table(pool:_:_:)over Perfect-CRUD 3'sDatabaseConnectionPool. - Request hardening (#14, #15, #17, #19). A 10 MiB default request body limit, upload temp files that only the server's user can read, no internal error text in responses, an idle timeout from the moment a connection is accepted, and a set of parser fixes for input that used to crash the server (see Fixes).
Breaking and behaviour changes
These are relative to the code on main before the release (which apps such as Perfect-Lasso, FMTestApp and PerfectTemplate tracked by branch) and, where noted, to the legacy branch.
- Not source compatible with the
legacybranch. See the README's "Migrating from the legacy branch". Upgrade: replaceroutes.bind(port:).listen().wait()withtry await Server(routes: routes, port: …).run(), make route closures@Sendable(andasyncwhere useful), move custom outputs tonextChunk(allocator:), and drop PerfectLib, Perfect-Mustache, Perfect-MIME, Perfect-CZlib-src and Perfect-CURL. There is no Mustache output. - Request bodies are capped at 10 MiB by default (#17). Larger bodies, uploads included, get 413 and the connection is closed. Upgrade: pass
maxRequestBodySize:(bytes, ornilfor no limit) toServerif you accept bigger bodies. - Error text is no longer sent to clients (#17, #19). Errors other than
ErrorOutput/TerminationTypebecome 500 "Internal server error." and are logged underperfect.nio.server. A failure to decode the request becomes 400: from thedecodemodifier (#17) and now also from a directHTTPRequest.decode(_:content:)call whoseDecodingErrorthe handler lets escape (it was a 500). That error is marked: its context'sunderlyingErroris now a publicRequestDecodingFailurewrapping the original;DecodingError.isRequestDecodingFailuretests for it andDecodingError.originalUnderlyingErrorreturns the decoder's own error. Any otherDecodingError, such as one from the handler's own data, stays a 500 logged at error, even after a handled request-decode failure. Upgrade: throwErrorOutput(status:description:)for anything the client should see. - Requests with a
..path segment, or a#, get 400 (#19). A path (everything before the first?) with a..segment, as sent or percent-encoded (%2e%2e,.%2e,..%2f,..%5c,..\, up to 7 layers of nested encoding, deeper nesting refused outright), or with..;…/..%00, is refused with 400 and the connection closed, before any route runs (WebSocket handshakes included), sowildandtrailingnever return a traversal segment. So is any request target containing#(conforming clients never send a fragment; it was a bypass). A request target longer than 8 KiB gets 414 URI Too Long. Dots within names (a..b,.hidden) are allowed. Upgrade: nothing, unless a client legitimately sends..segments; resolve them on the client. - Upload temp files (#15). They are created 0600 under
NSTemporaryDirectory()(they were 0666 in/tmp/), a failed upload fails the request (413, 507 or 500) instead of reaching the handler truncated, and a multipart body cut off before its closing boundary gets 400. Upgrade: if another user's process readstmpFileName, copy or re-permission the file in your handler; cleanup jobs matching/tmp/perfect_upload_*must look inTMPDIR. - A hand-built
WebSocketUpgradeHTTPOutputno longer upgrades (#18). Its public initializer is deprecated; such a route answers 426. Upgrade: declare the route withwebSocket(protocol:options:_:). - The idle timeout starts at accept and no longer cuts off steady responses (#19).
idleTimeoutused to be installed only once a request head had been routed, so a client that sent nothing or half a request head was never timed out; and it measured reads only, so any response that took longer than the timeout to deliver (a slow download, a long poll, server-sent events) was cut off mid-body. Now, while no response is pending, a connection is idle when nothing has been read for the timeout (covering the TLS handshake, the first request and keep-alive gaps); while a response is pending, it is idle only when the response is backed up and nothing more of it has been written or (per the kernel's TCP counters) has left the send buffer for the timeout. Writes are sliced to 16 KiB so progress is seen often. A client that closes its side while its response is pending is released once nothing has been written for the timeout (it used to pin the socket in CLOSE_WAIT until the route finished); the close is detected from the TCP state as well (any state other than ESTABLISHED, so a reset client counts too), since on macOS NIO doesn't read a FIN or RST that arrives while a response is pending. A response on a connection that closes after it (Connection: close, HTTP/1.0) is now fully flushed before the close (NIO dropped the queued tail before). It is still removed on a WebSocket upgrade. Upgrade: nothing, unless clients legitimately wait longer thanidleTimeout(default 60 s) before sending their first request; raise it or passnil. - A WebSocket handshake that isn't upgraded closes the connection (#19). NIO's HTTP decoder stops parsing a connection after any request that asks to upgrade, so the response to a refused or non-WebSocket handshake now says
Connection: closeand the connection is closed. Before, it was kept alive and the client's next request on it hung until the idle timeout. Upgrade: nothing; clients open a new connection. - A 1xx final status gets 500 (#19). A route returning 100 or 103 as its final status crashed the process (a precondition in NIO's protocol error handler); 101 left the connection confused. It is now logged and answered 500 with
Connection: close. Upgrade: nothing. - An output's Content-Length is enforced (#19). Equal duplicates are collapsed; conflicting or invalid values are dropped (logged) and the body framed without them; a body shorter or longer than the declared length is logged and the connection closed after it (a longer body is cut at the declared length: the bytes that fit are sent). They used to stay keep-alive and desync the next response. Upgrade: fix outputs that declare a wrong length.
- A non-WebSocket
Upgradeoffer is ignored (#19).Upgrade: h2c(curl--http2) and the like got an empty reply; the request is now served as HTTP/1.1 and the connection closed. Upgrade: nothing. - Responses are framed by the server; no body with HEAD, 204 or 304 (#19). The body was sent anyway, and a HEAD response for an output of unknown length (any
compressed()route, any streamingHTTPOutput) was framed as chunked and ended with a0\r\n\r\nterminator, so keep-alive clients and proxies read stray ...