Skip to content

Releases: PerfectlySoft/Perfect-NIO

Release list

Perfect-NIO 0.1.0

Choose a tag to compare

@taplin taplin released this 14 Oct 13:48
a1c2595

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-config and zlib1g-dev for the CZlib system library. macOS needs nothing extra.
  • PerfectNIOCRUD uses 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 async and @Sendable. Server(routes:port:…) replaces bind().listen().wait(): run() serves until the task is cancelled, withServer { port in … } gives a scoped server for tests. The response body is pulled from HTTPOutput.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 async WebSocketHandler. Only routes declared with webSocket(...) 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). TLSContextManager picks a certificate per SNI hostname and can be updated live; CertificateWatcher reloads renewed certificates; ACMEChallengeResponder answers HTTP-01 challenges; Server.httpRedirect() builds an HTTP→HTTPS redirect server.
  • Graceful restarts (#13). alwaysReusePort lets a replacement process bind the same port before the old one stops; reusePortCount opens several SO_REUSEPORT sockets 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's DatabaseConnectionPool.
  • 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 legacy branch. See the README's "Migrating from the legacy branch". Upgrade: replace routes.bind(port:).listen().wait() with try await Server(routes: routes, port: …).run(), make route closures @Sendable (and async where useful), move custom outputs to nextChunk(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, or nil for no limit) to Server if you accept bigger bodies.
  • Error text is no longer sent to clients (#17, #19). Errors other than ErrorOutput / TerminationType become 500 "Internal server error." and are logged under perfect.nio.server. A failure to decode the request becomes 400: from the decode modifier (#17) and now also from a direct HTTPRequest.decode(_:content:) call whose DecodingError the handler lets escape (it was a 500). That error is marked: its context's underlyingError is now a public RequestDecodingFailure wrapping the original; DecodingError.isRequestDecodingFailure tests for it and DecodingError.originalUnderlyingError returns the decoder's own error. Any other DecodingError, such as one from the handler's own data, stays a 500 logged at error, even after a handled request-decode failure. Upgrade: throw ErrorOutput(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), so wild and trailing never 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 reads tmpFileName, copy or re-permission the file in your handler; cleanup jobs matching /tmp/perfect_upload_* must look in TMPDIR.
  • A hand-built WebSocketUpgradeHTTPOutput no longer upgrades (#18). Its public initializer is deprecated; such a route answers 426. Upgrade: declare the route with webSocket(protocol:options:_:).
  • The idle timeout starts at accept and no longer cuts off steady responses (#19). idleTimeout used 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 than idleTimeout (default 60 s) before sending their first request; raise it or pass nil.
  • 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: close and 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 Upgrade offer 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 streaming HTTPOutput) was framed as chunked and ended with a 0\r\n\r\n terminator, so keep-alive clients and proxies read stray ...
Read more