Dec 14, 2023

vapor + plot

In this post you will follow along as I explore using John Sundell's Plot library to generate HTML for a basic Vapor web app.

pre-reqs

To try this yourself, you will need to install the following items on your local machine:

If you plan to use VSCode, you might also have a look at the post swift server + dev containers although a dev container is overkill for this.

background

In addition to Vapor, the team also maintains the excellent HTML template engine Leaf. Leaf is an expressive, full-featured HTML engine that supports everything needed to generate dynamic content for a website.

I don't really have any problems with Leaf, except for ya know, having to write HTML by hand. Certainly not a big deal, but I much prefer type safe code. This site is built with the static site generator Publish and one of its dependency libraries is Plot. With Plot I can write type safe code and generate HTML to be rendered on the fly. Let's have a look at how a basic Vapor site might integrate Plot for a Swift HTML DSL...

setup

Start with the basic hello Vapor template project:

mkdir hello && cd hello
vapor new hello -n

This command creates a 'Hello, World' Vapor app. Passing the argument -n tells the toolbox to not include Leaf. Next, add the Plot dependency to Package.swift:

.package(url: "https://github.com/JohnSundell/Plot.git", from: "0.14.0")

page modelling

To get started with Plot, I create a new file called Page.swift. I modeled a webpage with a Page class, which is the base class for individual pages to subclass. I know Swift likes us to use value types, but I think the class/subclass mental model maps well to Leaf template extending, so that is what I'll do.

import Plot

class Page {
    final func head() -> Node<HTML.DocumentContext> {
        .head(
            .meta(.charset(.utf8)),
            .siteName("Hello")
        )
    }

    final func body() -> Node<HTML.DocumentContext> {
        .body(
            .component(content())
        )
    }

    @ComponentBuilder
    func content() -> Component {
        Text("Default Content")
    }
}

This is a shell of a webpage and intentionally sparse (especially the head) for demo purposes. The idea is for subclasses to only override content() at the moment, but additional override methods could be defined for title(), description(), stylesheet() and so on.

I think the interesting part here is the use of @ComponentBuilder. This is a Swift result builder included in Plot that powers its Components API. The syntax of the Components API seems to be inspired by SwiftUI and feels immediately familiar.

Next, I create a very simple Page subclass:

// Hello.swift

import Plot

final class Hello: Page {

    @ComponentBuilder
    override func content() -> Component {
        Paragraph {
            Text("Hello, World!")
        }
    }
}

Here the content() is overridden and returns a Paragraph component which wraps a Text component.

protocol conformance

Up next is conformance to a couple of protocols. First is Renderable from the Plot package, which defines a single method render(indentedBy:)

extension Page: Renderable {
    func render(indentedBy indentationKind: Plot.Indentation.Kind?) -> String {
        HTML(
            head(),
            body()
        )
        .render(indentedBy: indentationKind)
    }
}

Here an HTML is initialized with head() and body(), then the render(indentedBy:) method is used to return a String as required.

The second conformance is AsyncResponseEncodable. This is a protocol defined in Vapor, which defines the method encodeResponse(for:). As the name implies, this method concurrently encodes a response.

import Vapor

extension Page: AsyncResponseEncodable {
    public func encodeResponse(for request: Request) async throws -> Response {
        let response = Response(status: .ok, body: .init(string: render(indentedBy: .spaces(4))))
        response.headers.add(name: "Content-Type", value: "text/html; charset=utf-8")
        return response
    }
}

This code builds a Response with the Page HTML, adds a content type header and returns the response.

pulling it together

With this out of the way, the only thing left to do is use it by updating routes.swift with:

func routes(_ app: Application) throws {
    app.get("hello") { req async -> Page in
        Hello()
    }
}

Build, run and browse to http://127.0.0.1:8080/hello. View the page source and here is the HTML

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
        <meta charset="UTF-8"/>
        <meta property="og:site_name" content="Hello"/>
    </head>
    <body>
        <p>Hello, World!</p></body>
</html>

performance

I implemented some crude unit tests just to see how performance compared. Basic setup was to render "Hello, World" in <p> tags x1000 and repeating that for many iterations. This is certainly a non-exhaustive test procedure in the sense that it doesn't take full advantage of either lib's features, but is enough for a rough comparison.

Given the following note in the readme for Plot, I was not shocked by the results:

Another important note is that, although Plot has been heavily optimized
across the board, Component-based elements do require a bit of extra processing
compared to Node-based ones — so in situations where maximum performance is 
required, you might want to stick to the Node-based API.

Memory was pretty close, both measuring 15000KB - 16000KB.

Clock time was consistently a different story:

Plot: 1.57s
Leaf: 0.8s

So for giggles, what if I use the Node-based API instead of the Component-based API?

I added a new subclass called HelloNode with the following implementation:

import Plot

final class HelloNode: Page {
    let count: Int

    init(count: Int) {
        self.count = count
    }

    override func body() -> Node<HTML.DocumentContext> {
        .body(.forEach(1...count, { number in
            .p("Hello, World")
        }))
    }
}

Back in Page.swift I removed the final declaration from body() and overrode it in the new subclass using only Plot's Node-based APIs. I figured there would be a modest increase in clock time performance, but this time I was actually shocked.

Plot (Component): 1.57s
Leaf: 0.8s
Plot (Node): 0.63s

Wow, now it comes in even fast than Leaf's rendering engine 🤯. I'll be the first to admit more could be done with the test cases. For now though, I'm comfortable following Plot's guidance to use the Component API for most things and the Node API in performance sensitive situations.


Check out the example repos for the code in post:

jagreenwood/examples


update 12.15.23

🎉 Thank you @mattiem for pointing out the unneeded Task.

➕ Added a section on performance