A few years ago, I open sourced my Swift on the Server frame, Meridian. There have been a few big updates in the intervening time which I wanted to talk about here.

I’ve now deployed Meridian for a number of sites and projects, and there have been a lot of changes and fixes to make it more reliable and allow it to be deployed for long periods of time without restarts. However, Meridian’s big pitch has always been that its a joy to write web applications in, so I want to focus more on some of the changes to the developer experience.

The Pitch

If you haven’t seen Meridian yet, the short pitch is that it’s a web framework which draws a lot of its design inspiration from SwiftUI. It seeks to make all inputs to your responder the same. Here’s a sample responder that you’ll be familiar with if you’ve ever written an API for an iPhone app:

public struct AddDeviceRoute: Responder {

    public init() {}

    @EnvironmentObject var database: Database

    @Auth var auth

    @JSONValue("token") var token: String

    public func execute() async throws -> any Response {
        try await database.addDevice(token: token, forAccount: auth)
        return JSON(Success())
    }
}

Everything is handled with a property wrapper — query parameters, URL parameters, JSON, headers, internal dependencies like a database. You can even create your own property wrappers that extract data in any form you like.

With Meridian, you declare your dependencies (each represented by a property wrapper), and it ensures that all those dependencies are fulfilled before running your execute() function, which can stay focused on business logic.

async/await

First and foremost, Meridian now supports async/await. Because synchronous functions can fulfill asynchonous protocol requirements, this change is totally backwards compatible and opt-in, and that makes it really easy to gradually migrate to.

Being able to use async/await also lets Meridian play in the wider sphere of Swift on the Server packages. AsyncHTTPClient, PostgresNIO, and APNSwift now all fit nicely into Meridian and its environment. When custom executor support is ready for NIO, that also should provide a performance bump to users of Meridian with very few changes (or more likely, none at all) required to the user’s code.

Websockets

One of the apps I built needed to make very heavy use of websockets, so I worked on support in Meridian. The code for this went in about a year ago and fortunately wasn’t too gnarly. It relies heavily on NIOs built-in helpers for upgrading a regular HTTP request to a bidirectional websocket request.

Keeping the interfaces feeling like Meridian is one of the most important parts of the design of Meridan, and when designing this feature, I wanted to stay true to the design ethos of the rest of the library.

The heavy lifting from NIO plus a little of Meridian’s syntactic sugar magic allows the websocket responder to look very similar to any other responder in Meridian, with access to all the same property wrappers that a regular request can use:

struct WebSocketTester: WebSocketResponder {

    @Path var path

    func connected(to webSocket: WebSocket) async throws {

        print("Connected to websocket")

        for try await message in webSocket.textMessages {
            print("Received \(message) at \(path)")
            webSocket.send(text: "String: \(message) is \(message.count) characters long")
        }

        print("Websocket closed!")
    }
}

It even uses AsyncSequences so that you can use Swift’s for try await syntax for iterating over incoming messages, and mix-and-match that code with other await-able code.

Middleware

Like websockets, middleware also needs to fit with the rest of the library. Take, for example, block-based HTTP frameworks like Express.js. They have the benefit that everything looks almost exactly the same. Here’s a middleware and a route handler in Express:

router.use((req, res, next) => {
  console.log(`Request: ${req.method) ${req.route.path}`)
  next()
})

router.get('/user/:id', (req, res) => {
  res.send('hello, user!')
})

These two chunks of code are very similar, so you can use the same techniques you learn for writing responders for writing middleware. (Due to a quirk in JavaScript, functions that accept fewer parameters can be passed as arguments that expect more parameters, and the later parameters are simply ignored, meaning that (req, res) => and (req, res, next) => are actually hooking into the same thing. Anathema for a type-minded Swift developer, but it works in JavaScript.)

In Meridian, these look like this:

public struct LoggingMiddleware: Middleware {

    @Path var path
    
    @RequestMethod var method

    public init() { }

    public func execute(next: Responder) async throws -> Response {
        print("Request: \(method) \(path)")
        return try await next.execute()
    }
}

struct HelloUser: Responder {

    func execute() throws -> Response {
        "Hello, user!"
    }

}

Similar to Express, there are very few differences between a middleware and a bog standard route: 1) conforming to a different protocol, and 2) adding a next: Responder argument so that the chain can be continued. Middleware, like websockets, has full access to all the property wrappers you’d care to use, as well as being an async and error-friendly environment.

The Future

I’ve been using Meridian heavily in my work, so I’m really invested in making it better. There are two big areas of improvement that I’m going to be focused on this year.

First, macros. Adding things to Meridian’s environment is similar to adding things to SwiftUI’s environment, and the new Entry() macro introduced this year at WWDC 2024 will fit very nicely with Meridian. I also think there’s some room for a macro that runs a computed variable only once, so that you can use a variable multiple times without, e.g., loading things from the database more than once.

Second, my white whale — OpenAPI. I’ve actually started on this work, and while it looks like it will be conceptually possible, it’s definitely going to be an uphill battle with some of the more complex representations of data. The goal here is to write your endpoints in Meridian, and then have your endpoints magically show up in your client-side Swift code, ready for autocomplete.