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:
- Xcode or VSCode
- Vapor Toolbox
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:
update 12.15.23
🎉 Thank you @mattiem for pointing out the unneeded Task
.
➕ Added a section on performance