swift: TodoMVC on Hummingbird 2 and SQLiteNIO

Server-rendered list that works with JavaScript off, with a small ES
module that sends the same form posts with fetch. Unit tests with Swift
Testing; the shared suite passes locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d5e64b274ff37193c6f67f40c285169209ca80df parent 00ab612
18 files changed, +2235 −0
  • apps/swift/.gitignore +6 −0
  • apps/swift/DEPS +3 −0
  • apps/swift/Makefile +75 −0
  • apps/swift/Package.resolved +231 −0
  • apps/swift/Package.swift +30 −0
  • apps/swift/Sources/TodoApp/Assets.swift +692 −0
  • apps/swift/Sources/TodoApp/Page.swift +159 −0
  • apps/swift/Sources/TodoApp/Routes.swift +139 −0
  • apps/swift/Sources/TodoApp/Server.swift +111 −0
  • apps/swift/Sources/TodoApp/Static.swift +14 −0
  • apps/swift/Sources/TodoApp/Store.swift +176 −0
  • apps/swift/Sources/TodoApp/Visitors.swift +77 −0
  • apps/swift/Sources/todo/main.swift +11 −0
  • apps/swift/Tests/TodoAppTests/RouteTests.swift +225 −0
  • apps/swift/Tests/TodoAppTests/StoreTests.swift +120 −0
  • apps/swift/static/app.css +61 −0
  • apps/swift/static/app.js +77 −0
  • apps/swift/tools/embed.sh +28 −0
diff --git a/apps/swift/.gitignore b/apps/swift/.gitignore
new file mode 100644
index 0000000..579c510
--- /dev/null
+++ b/apps/swift/.gitignore
@@ -0,0 +1,6 @@
+/.cache/
+/dist/
+/Sources/TodoApp/Version.swift
+*.db
+*.db-shm
+*.db-wal
diff --git a/apps/swift/DEPS b/apps/swift/DEPS
new file mode 100644
index 0000000..7e325d3
--- /dev/null
+++ b/apps/swift/DEPS
@@ -0,0 +1,3 @@
+github.com/hummingbird-project/hummingbird
+github.com/vapor/sqlite-nio
+github.com/apple/swift-http-types
diff --git a/apps/swift/Makefile b/apps/swift/Makefile
new file mode 100644
index 0000000..2aa7e51
--- /dev/null
+++ b/apps/swift/Makefile
@@ -0,0 +1,75 @@
+# todo-swift: see README.md.
+#
+# Everything SwiftPM fetches or compiles stays under .cache/ (sources in
+# .cache/swiftpm and .cache/build/checkouts, compiled output in
+# .cache/build/<triple>), so `make clean-deps` can remove the compiled
+# part without touching any other project.
+
+SHELL := bash
+ROOT := $(abspath ../..)
+SWIFT := source $(ROOT)/scripts/env.sh && swift
+SWIFTPM := --scratch-path .cache/build --cache-path .cache/swiftpm
+ARM64_SDK := aarch64-swift-linux-musl
+DIRTY := $(shell git status --porcelain -- . 2>/dev/null | grep -q . && echo -dirty)
+VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)$(DIRTY)
+MSG ?= todo-swift $(VERSION)
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy generate run
+
+# Embeds the CSS and JS into the binary and stamps the build version.
+# Each file is only rewritten when its content changes.
+generate:
+ @tools/embed.sh $(ROOT)/shared static Sources/TodoApp/Assets.swift
+ @mkdir -p .cache
+ @printf '// GENERATED by make. The build version /api/version reports.\npublic let buildVersion = "%s"\n' '$(VERSION)' > .cache/Version.swift.new
+ @if cmp -s .cache/Version.swift.new Sources/TodoApp/Version.swift; then rm .cache/Version.swift.new; \
+ else mv .cache/Version.swift.new Sources/TodoApp/Version.swift; fi
+
+build: generate
+ $(SWIFT) build -c release $(SWIFTPM)
+ @mkdir -p dist
+ cp .cache/build/release/todo dist/app
+
+build-arm64: generate
+ $(SWIFT) build -c release $(SWIFTPM) --swift-sdk $(ARM64_SDK) -Xlinker --strip-all
+ @mkdir -p dist
+ cp .cache/build/$(ARM64_SDK)/release/todo dist/app-linux-arm64
+ echo '$(VERSION)' > dist/app-linux-arm64.version
+ @file dist/app-linux-arm64 | grep -q 'ARM aarch64.*statically linked' \
+ || { echo "dist/app-linux-arm64 is not a static arm64 binary"; file dist/app-linux-arm64; exit 1; }
+
+test: generate
+ $(SWIFT) test $(SWIFTPM)
+
+# Runs the local build on 127.0.0.1 for the shared suite.
+PORT ?= 8080
+run: build
+ dist/app --listen 127.0.0.1:$(PORT) --db $${TMPDIR:-/tmp}/todo-swift.db
+
+clean:
+ rm -rf dist
+
+# Removes every compiled artifact, ours and the dependencies', and keeps the
+# downloaded sources (.cache/swiftpm, .cache/build/checkouts and
+# .cache/build/repositories), so the next build compiles everything again.
+clean-deps: clean
+ @[ ! -d .cache/build ] || find .cache/build -mindepth 1 -maxdepth 1 \
+ ! -name checkouts ! -name repositories ! -name artifacts ! -name workspace-state.json \
+ -exec rm -rf {} +
+
+bump:
+ sed -i 's/^let bumpNote = "unbumped"$$/let bumpNote = "bumped"/' Sources/TodoApp/Routes.swift
+ grep -q '^let bumpNote = "bumped"$$' Sources/TodoApp/Routes.swift
+
+unbump:
+ sed -i 's/^let bumpNote = "bumped"$$/let bumpNote = "unbumped"/' Sources/TodoApp/Routes.swift
+ grep -q '^let bumpNote = "unbumped"$$' Sources/TodoApp/Routes.swift
+
+# Ships dist/app-linux-arm64. Build it from a clean commit first: the
+# version it reports is that commit's short SHA.
+deploy:
+ @[ -f dist/app-linux-arm64 ] || { echo "run make build-arm64 first"; exit 1; }
+ @case "$(VERSION)" in *-dirty) echo "commit first: the tree has changes"; exit 1;; esac
+ @[ "$$(cat dist/app-linux-arm64.version)" = '$(VERSION)' ] \
+ || { echo "dist/app-linux-arm64 wasn't built at $(VERSION); run make build-arm64"; exit 1; }
+ carlos deploy --account bfj --app todo-swift --target linux-arm64 --message "$(MSG)" dist/app-linux-arm64
diff --git a/apps/swift/Package.resolved b/apps/swift/Package.resolved
new file mode 100644
index 0000000..bbcd8c1
--- /dev/null
+++ b/apps/swift/Package.resolved
@@ -0,0 +1,231 @@
+{
+ "originHash" : "11db42395ee07fa8d0404f7e6b930280fc22baa897020b5c3e7d79f849241b31",
+ "pins" : [
+ {
+ "identity" : "async-http-client",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/async-http-client.git",
+ "state" : {
+ "revision" : "4c005f955e83f888d5616e579717a36d2dfc6301",
+ "version" : "1.36.2"
+ }
+ },
+ {
+ "identity" : "hummingbird",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/hummingbird-project/hummingbird.git",
+ "state" : {
+ "revision" : "e12178302dacb8226fc78157f89078a59c75dd98",
+ "version" : "2.28.0"
+ }
+ },
+ {
+ "identity" : "sqlite-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/vapor/sqlite-nio.git",
+ "state" : {
+ "revision" : "52fd69b9b2ee614d90ba277616fb6cc98ea5a64c",
+ "version" : "1.13.2"
+ }
+ },
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms.git",
+ "state" : {
+ "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2",
+ "version" : "1.7.3"
+ }
+ },
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms.git",
+ "state" : {
+ "revision" : "13713a4ffdee8abd929f92568ee9462a46ae26e0",
+ "version" : "1.1.7"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
+ "version" : "1.3.1"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177",
+ "version" : "1.21.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "844289ac455816e193f1dbbe014f3c4dbe429410",
+ "version" : "1.7.4"
+ }
+ },
+ {
+ "identity" : "swift-configuration",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-configuration.git",
+ "state" : {
+ "revision" : "4e916072eb34f3c136ae64bd2859d6072cdb1b6e",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "c2cbadd7263439d8015189668f96ff5b5be565ce",
+ "version" : "5.0.3"
+ }
+ },
+ {
+ "identity" : "swift-distributed-tracing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-distributed-tracing.git",
+ "state" : {
+ "revision" : "297b5b9c82a5631e3199ce3ec0993a3cfb0fbb84",
+ "version" : "1.7.2"
+ }
+ },
+ {
+ "identity" : "swift-http-structured-headers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-structured-headers.git",
+ "state" : {
+ "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47",
+ "version" : "1.7.0"
+ }
+ },
+ {
+ "identity" : "swift-http-types",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-types.git",
+ "state" : {
+ "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50",
+ "version" : "1.8.0"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log.git",
+ "state" : {
+ "revision" : "c9c6bb9d275ec0d95c3ad16391f3f045dc623104",
+ "version" : "1.16.2"
+ }
+ },
+ {
+ "identity" : "swift-metrics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-metrics.git",
+ "state" : {
+ "revision" : "9467f9cc3c32b4386fad1ecc4618328887ef5758",
+ "version" : "2.12.0"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa",
+ "version" : "2.104.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-extras.git",
+ "state" : {
+ "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced",
+ "version" : "1.35.1"
+ }
+ },
+ {
+ "identity" : "swift-nio-http2",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-http2.git",
+ "state" : {
+ "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69",
+ "version" : "1.46.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-ssl",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-ssl.git",
+ "state" : {
+ "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440",
+ "version" : "2.37.5"
+ }
+ },
+ {
+ "identity" : "swift-nio-transport-services",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-transport-services.git",
+ "state" : {
+ "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5",
+ "version" : "1.28.0"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-service-context",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-service-context.git",
+ "state" : {
+ "revision" : "853ba3d9ce78851b9cc20108e8bd4f13f7fa546c",
+ "version" : "1.5.1"
+ }
+ },
+ {
+ "identity" : "swift-service-lifecycle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/swift-service-lifecycle.git",
+ "state" : {
+ "revision" : "a18ee16b8039866fd919bdb4e8e906882d4ecc75",
+ "version" : "2.13.0"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
+ "version" : "1.8.1"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/apps/swift/Package.swift b/apps/swift/Package.swift
new file mode 100644
index 0000000..53cd5c5
--- /dev/null
+++ b/apps/swift/Package.swift
@@ -0,0 +1,30 @@
+// swift-tools-version:6.0
+import PackageDescription
+
+let package = Package(
+ name: "todo-swift",
+ platforms: [.macOS(.v14)],
+ dependencies: [
+ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.28.0"),
+ .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.13.2"),
+ .package(url: "https://github.com/apple/swift-http-types.git", from: "1.4.0"),
+ ],
+ targets: [
+ .target(
+ name: "TodoApp",
+ dependencies: [
+ .product(name: "Hummingbird", package: "hummingbird"),
+ .product(name: "SQLiteNIO", package: "sqlite-nio"),
+ .product(name: "HTTPTypes", package: "swift-http-types"),
+ ]
+ ),
+ .executableTarget(name: "todo", dependencies: ["TodoApp"]),
+ .testTarget(
+ name: "TodoAppTests",
+ dependencies: [
+ "TodoApp",
+ .product(name: "HummingbirdTesting", package: "hummingbird"),
+ ]
+ ),
+ ]
+)
diff --git a/apps/swift/Sources/TodoApp/Assets.swift b/apps/swift/Sources/TodoApp/Assets.swift
new file mode 100644
index 0000000..14ada2e
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Assets.swift
@@ -0,0 +1,692 @@
+// GENERATED by tools/embed.sh from shared/ and static/. Do not edit.
+
+enum Assets {
+ static let baseCSS = #"""
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
+
+"""#
+
+ static let indexCSS = #"""
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
+
+"""#
+
+ static let appCSS = #"""
+/* The controls a page without JavaScript uses. app.js's inline starter puts
+ the class "js" on <html>, and then they're hidden: the checkboxes,
+ double-click and blur take over, exactly as in TodoMVC. */
+.js .nojs {
+ display: none !important;
+}
+
+/* Forms that only wrap a control take no part in layout. */
+form.inline {
+ display: contents;
+}
+
+/* Without JavaScript, a submit button lies exactly over each checkbox, so
+ the page looks the same either way. */
+.todo-list li .view > button[data-action="toggle"] {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+.main button[data-action="toggle-all"] {
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+/* Without JavaScript there's no double-click, so each item has an edit link
+ beside the delete button. */
+.todo-list li .view > a[data-action="edit"] {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 55px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 20px;
+ line-height: 40px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .view > a[data-action="edit"]:hover,
+.todo-list li .view > a[data-action="edit"]:focus {
+ color: #484848;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 90px;
+}
+
+"""#
+
+ static let appJS = #"""
+// TodoMVC behaviour on top of the server-rendered page. Every change is
+// still the page's own form post; this sends it with fetch and swaps in
+// the new list instead of loading the page again.
+
+const app = document.querySelector('.todoapp');
+
+// Sends a form and replaces section.main and footer.footer with what the
+// server answers. Anything unexpected falls back to a full page load.
+async function send(form) {
+ if (form.dataset.sending) return;
+ form.dataset.sending = '1';
+ try {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ headers: { 'X-Todo-Partial': '1' },
+ body: new URLSearchParams(new FormData(form)),
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ swap(await res.text());
+ if (form.closest('.header')) form.reset();
+ } catch {
+ location.reload();
+ } finally {
+ delete form.dataset.sending;
+ }
+}
+
+function swap(html) {
+ const tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove());
+ app.append(tpl.content);
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ send(e.target);
+});
+
+app.addEventListener('change', (e) => {
+ const li = e.target.closest('li[data-id]');
+ if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`));
+ if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form);
+});
+
+// Editing: double-click opens the editor, Enter or blur saves, Escape
+// cancels. An empty title deletes the item (the server decides that).
+function startEditing(li) {
+ li.classList.add('editing');
+ const input = li.querySelector('.edit');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+}
+
+app.addEventListener('dblclick', (e) => {
+ const li = e.target.closest('.todo-list li');
+ if (li && e.target.matches('.view label')) startEditing(li);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const input = e.target;
+ input.value = input.defaultValue;
+ input.closest('li').classList.remove('editing');
+ input.blur();
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (li.classList.contains('editing')) send(e.target.form);
+});
+
+// The server's edit view (?edit=<id>) opens with that item's editor; put
+// the cursor at the end, as double-click does.
+const editing = app.querySelector('.todo-list li.editing');
+if (editing) startEditing(editing);
+
+"""#
+
+}
diff --git a/apps/swift/Sources/TodoApp/Page.swift b/apps/swift/Sources/TodoApp/Page.swift
new file mode 100644
index 0000000..91a07e9
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Page.swift
@@ -0,0 +1,159 @@
+/// The three views of a list, named as the contract's `filter` field names them.
+public enum Filter: String, Sendable, CaseIterable {
+ case all, active, completed
+
+ /// A missing or unknown value means `all`.
+ init(field: String?) {
+ self = field.flatMap(Filter.init(rawValue:)) ?? .all
+ }
+
+ var path: String {
+ switch self {
+ case .all: "/"
+ case .active: "/active"
+ case .completed: "/completed"
+ }
+ }
+
+ func includes(_ todo: Todo) -> Bool {
+ switch self {
+ case .all: true
+ case .active: !todo.completed
+ case .completed: todo.completed
+ }
+ }
+}
+
+/// Server-rendered HTML. Everything a visitor typed goes through `escape`.
+///
+/// The markup follows `shared/reference.html`. The controls marked
+/// `class="nojs"` are what a page without JavaScript uses; `app.css` lays the
+/// toggle buttons exactly over the checkboxes they stand in for, and hides
+/// every `nojs` control once the `js` class is on `<html>`.
+enum Page {
+ static let language = "Swift"
+
+ static func document(todos: [Todo], filter: Filter, editing: Int?, version: String) -> String {
+ var html = """
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <script>document.documentElement.className = "js"</script>
+ <link rel="stylesheet" href="/static/base.css">
+ <link rel="stylesheet" href="/static/index.css">
+ <link rel="stylesheet" href="/static/app.css?v=\(escape(version))">
+ <script type="module" src="/static/app.js?v=\(escape(version))"></script>
+ </head>
+ <body>
+ <section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ <form method="post" action="/todos">
+ \(filterField(filter))<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"\(editing == nil ? " autofocus" : "")>
+ </form>
+ </header>
+
+ """
+ html += body(todos: todos, filter: filter, editing: editing)
+ html += """
+ </section>
+ <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · \(language)</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+ </html>
+
+ """
+ return html
+ }
+
+ /// `section.main` and `footer.footer`, or nothing when the list is empty.
+ /// The page's JavaScript swaps this in after each action.
+ static func body(todos: [Todo], filter: Filter, editing: Int?) -> String {
+ guard !todos.isEmpty else { return "" }
+ let left = todos.count { !$0.completed }
+ let done = todos.count - left
+ var html = """
+ <section class="main">
+ <input id="toggle-all" class="toggle-all" type="checkbox"\(left == 0 ? " checked" : "")>
+ <label for="toggle-all">Mark all as complete</label>
+ <form class="inline" method="post" action="/todos/toggle-all">\(filterField(filter))<button class="nojs" data-action="toggle-all">Mark all as complete</button></form>
+ <ul class="todo-list">
+
+ """
+ for todo in todos where filter.includes(todo) {
+ html += item(todo, filter: filter, editing: todo.id == editing)
+ }
+ html += """
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>\(left)</strong> \(left == 1 ? "item" : "items") left</span>
+ <ul class="filters">
+
+ """
+ for (f, name) in [(Filter.all, "All"), (.active, "Active"), (.completed, "Completed")] {
+ html += "<li><a\(f == filter ? " class=\"selected\"" : "") href=\"\(f.path)\">\(name)</a></li>\n"
+ }
+ html += "</ul>\n"
+ if done > 0 {
+ html += """
+ <form class="inline" method="post" action="/todos/clear-completed">\(filterField(filter))<button class="clear-completed">Clear completed</button></form>
+
+ """
+ }
+ html += "</footer>\n"
+ return html
+ }
+
+ static func item(_ todo: Todo, filter: Filter, editing: Bool) -> String {
+ let id = todo.id
+ let title = escape(todo.title)
+ var classes: [String] = []
+ if todo.completed { classes.append("completed") }
+ if editing { classes.append("editing") }
+ let classAttr = classes.isEmpty ? "" : " class=\"\(classes.joined(separator: " "))\""
+ let editHref = "\(filter.path)?edit=\(id)"
+ return """
+ <li\(classAttr) data-id="\(id)">
+ <form id="toggle-\(id)" method="post" action="/todos/\(id)/toggle" hidden>\(filterField(filter))</form>
+ <form id="destroy-\(id)" method="post" action="/todos/\(id)/destroy" hidden>\(filterField(filter))</form>
+ <div class="view">
+ <input class="toggle" type="checkbox"\(todo.completed ? " checked" : "") aria-label="Toggle">
+ <label>\(title)</label>
+ <button class="nojs" data-action="toggle" form="toggle-\(id)">Toggle</button>
+ <a class="nojs" data-action="edit" href="\(editHref)" title="Edit">✎</a>
+ <button class="destroy" form="destroy-\(id)" aria-label="Delete"></button>
+ </div>
+ <form method="post" action="/todos/\(id)/edit">\(filterField(filter))<input class="edit" name="title" value="\(title)" autocomplete="off"\(editing ? " autofocus" : "")></form>
+ </li>
+
+ """
+ }
+
+ static func filterField(_ filter: Filter) -> String {
+ "<input type=\"hidden\" name=\"filter\" value=\"\(filter.rawValue)\">"
+ }
+
+ /// Escapes text for use in HTML content and in double-quoted attributes.
+ static func escape(_ text: String) -> String {
+ var out = ""
+ out.reserveCapacity(text.utf8.count)
+ for c in text.unicodeScalars {
+ switch c {
+ case "&": out += "&amp;"
+ case "<": out += "&lt;"
+ case ">": out += "&gt;"
+ case "\"": out += "&quot;"
+ case "'": out += "&#39;"
+ default: out.unicodeScalars.append(c)
+ }
+ }
+ return out
+ }
+}
diff --git a/apps/swift/Sources/TodoApp/Routes.swift b/apps/swift/Sources/TodoApp/Routes.swift
new file mode 100644
index 0000000..6557e16
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Routes.swift
@@ -0,0 +1,139 @@
+import HTTPTypes
+import Hummingbird
+
+/// `make bump` changes this text so the next build has to recompile this
+/// file, the one holding the `GET /` handler; `make unbump` puts it back.
+let bumpNote = "unbumped"
+
+/// The request header the page's JavaScript sends with each action. With
+/// it, an action answers 200 with the new `section.main` and
+/// `footer.footer` instead of 303.
+let partialHeader = HTTPField.Name("X-Todo-Partial")!
+
+/// Every route the contract lists.
+struct Routes: Sendable {
+ let store: Store
+ let visitors: Visitors
+ let version: String
+
+ func register(on router: Router<BasicRequestContext>) {
+ router.get("/healthz") { _, _ in "ok\n" }
+ router.get("/api/version") { _, _ in self.version }
+
+ router.get("/static/base.css") { _, _ in Assets.response(Assets.baseCSS, type: "text/css; charset=utf-8") }
+ router.get("/static/index.css") { _, _ in Assets.response(Assets.indexCSS, type: "text/css; charset=utf-8") }
+ router.get("/static/app.css") { _, _ in Assets.response(Assets.appCSS, type: "text/css; charset=utf-8") }
+ router.get("/static/app.js") { _, _ in Assets.response(Assets.appJS, type: "text/javascript; charset=utf-8") }
+
+ for filter in Filter.allCases {
+ router.get(RouterPath(filter.path)) { request, _ in
+ try await self.page(request, filter: filter)
+ }
+ }
+
+ router.post("/todos") { request, context in
+ try await self.action(request, context) { list, form in
+ let title = form.title?.trimmingWhitespace() ?? ""
+ if !title.isEmpty { try await self.store.add(list: list, title: title) }
+ return true
+ }
+ }
+ router.post("/todos/toggle-all") { request, context in
+ try await self.action(request, context) { list, _ in
+ try await self.store.toggleAll(list: list)
+ return true
+ }
+ }
+ router.post("/todos/clear-completed") { request, context in
+ try await self.action(request, context) { list, _ in
+ try await self.store.clearCompleted(list: list)
+ return true
+ }
+ }
+ router.post("/todos/:id/toggle") { request, context in
+ let id = try context.parameters.require("id", as: Int.self)
+ return try await self.action(request, context) { list, _ in
+ try await self.store.toggle(list: list, id: id)
+ }
+ }
+ router.post("/todos/:id/edit") { request, context in
+ let id = try context.parameters.require("id", as: Int.self)
+ return try await self.action(request, context) { list, form in
+ let title = form.title?.trimmingWhitespace() ?? ""
+ return title.isEmpty
+ ? try await self.store.destroy(list: list, id: id)
+ : try await self.store.rename(list: list, id: id, title: title)
+ }
+ }
+ router.post("/todos/:id/destroy") { request, context in
+ let id = try context.parameters.require("id", as: Int.self)
+ return try await self.action(request, context) { list, _ in
+ try await self.store.destroy(list: list, id: id)
+ }
+ }
+ }
+
+ /// `GET /`, `/active` and `/completed`, with `?edit=<id>` opening that
+ /// item's editor.
+ func page(_ request: Request, filter: Filter) async throws -> Response {
+ let visitor = try await visitors.resolve(request)
+ let todos = try await store.todos(list: visitor.list)
+ let editing = request.uri.queryParameters["edit"].flatMap { Int(String($0)) }
+ let html = Page.document(todos: todos, filter: filter, editing: editing, version: version)
+ var response = Response.html(html)
+ visitors.attach(visitor, to: &response)
+ return response
+ }
+
+ struct Form: Decodable {
+ var title: String?
+ var filter: String?
+ }
+
+ /// Runs one form action for the caller's own list. `change` answers
+ /// false when the item it names isn't in that list, which is a 404.
+ func action(
+ _ request: Request,
+ _ context: BasicRequestContext,
+ change: (Int, Form) async throws -> Bool
+ ) async throws -> Response {
+ var request = request
+ let body = try await request.collectBody(upTo: context.maxUploadSize)
+ guard let form = try? URLEncodedFormDecoder().decode(Form.self, from: String(buffer: body)) else {
+ throw HTTPError(.badRequest, message: "Expected a form.")
+ }
+ let filter = Filter(field: form.filter)
+ let visitor = try await visitors.resolve(request)
+
+ var response: Response
+ if try await change(visitor.list, form) {
+ if request.headers[partialHeader] != nil {
+ let todos = try await store.todos(list: visitor.list)
+ response = .html(Page.body(todos: todos, filter: filter, editing: nil))
+ } else {
+ response = Response(status: .seeOther, headers: [.location: filter.path])
+ }
+ } else {
+ response = Response(status: .notFound, headers: [.contentType: "text/plain; charset=utf-8"],
+ body: .init(byteBuffer: ByteBuffer(string: "No such item in your list.\n")))
+ }
+ visitors.attach(visitor, to: &response)
+ return response
+ }
+}
+
+extension Response {
+ static func html(_ html: String) -> Response {
+ Response(
+ status: .ok,
+ headers: [.contentType: "text/html; charset=utf-8", .cacheControl: "no-store"],
+ body: .init(byteBuffer: ByteBuffer(string: html))
+ )
+ }
+}
+
+extension String {
+ func trimmingWhitespace() -> String {
+ String(self[...].trimmingWhitespace())
+ }
+}
diff --git a/apps/swift/Sources/TodoApp/Server.swift b/apps/swift/Sources/TodoApp/Server.swift
new file mode 100644
index 0000000..88ee930
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Server.swift
@@ -0,0 +1,111 @@
+import Foundation
+import Hummingbird
+import Logging
+
+/// How the binary was started: `--socket <path> --db <path>` on Carloku,
+/// or `--listen 127.0.0.1:<port> --db <path>` for local runs and tests.
+public struct Options: Sendable, Equatable {
+ public enum Address: Sendable, Equatable {
+ case socket(String)
+ case listen(host: String, port: Int)
+ }
+
+ public var address: Address
+ public var database: String
+ /// `CARLOS_HOST`, the instance's hostname, if the platform set it.
+ public var carlosHost: String?
+
+ public struct UsageError: Error, CustomStringConvertible {
+ public var description: String
+ }
+
+ public static let usage = "usage: todo (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
+
+ public init(arguments: [String], environment: [String: String]) throws {
+ var socket: String?
+ var listen: String?
+ var database: String?
+ var rest = arguments[...]
+ while let flag = rest.popFirst() {
+ guard let value = rest.popFirst() else { throw UsageError(description: "\(flag) needs a value\n\(Self.usage)") }
+ switch flag {
+ case "--socket", "-socket": socket = value
+ case "--listen", "-listen": listen = value
+ case "--db", "-db": database = value
+ default: throw UsageError(description: "unknown flag \(flag)\n\(Self.usage)")
+ }
+ }
+ guard let database else { throw UsageError(description: Self.usage) }
+ self.database = database
+ switch (socket, listen) {
+ case (let path?, nil):
+ address = .socket(path)
+ case (nil, let listen?):
+ guard let colon = listen.lastIndex(of: ":"), listen[..<colon] == "127.0.0.1",
+ let port = Int(listen[listen.index(after: colon)...]), (1...65535).contains(port)
+ else { throw UsageError(description: "--listen takes 127.0.0.1:<port>; nothing else is bound") }
+ address = .listen(host: "127.0.0.1", port: port)
+ default:
+ throw UsageError(description: Self.usage)
+ }
+ carlosHost = environment["CARLOS_HOST"].flatMap { $0.isEmpty ? nil : $0 }
+ }
+
+ /// The origin a browser sends with the app's own form posts.
+ var origin: String? {
+ switch address {
+ case .listen(let host, let port): "http://\(host):\(port)"
+ case .socket: carlosHost.map { "https://\($0)" }
+ }
+ }
+
+ /// On a socket the platform's edge serves the app over https.
+ var servedOverHTTPS: Bool {
+ if case .socket = address { return true }
+ return false
+ }
+}
+
+public enum Server {
+ /// Builds the router for a store; the tests drive this directly.
+ public static func router(store: Store, options: Options, version: String = buildVersion) -> Router<BasicRequestContext> {
+ let router = Router()
+ router.add(middleware: OriginCheck(origin: options.origin))
+ Routes(store: store, visitors: Visitors(store: store, secure: options.servedOverHTTPS), version: version)
+ .register(on: router)
+ return router
+ }
+
+ /// Serves until SIGTERM or SIGINT, then closes the database and returns.
+ public static func run(_ options: Options) async throws {
+ var logger = Logger(label: "todo-swift")
+ logger.logLevel = .info
+ let store = try await Store.open(path: options.database)
+
+ let address: BindAddress
+ switch options.address {
+ case .socket(let path):
+ // A socket file left behind by an earlier process would make bind fail.
+ try? FileManager.default.removeItem(atPath: path)
+ address = .unixDomainSocket(path: path)
+ case .listen(let host, let port):
+ address = .hostname(host, port: port)
+ }
+
+ let app = Application(
+ router: router(store: store, options: options),
+ configuration: .init(address: address),
+ logger: logger
+ )
+ do {
+ try await app.runService()
+ } catch {
+ try? await store.close()
+ throw error
+ }
+ try await store.close()
+ if case .socket(let path) = options.address {
+ try? FileManager.default.removeItem(atPath: path)
+ }
+ }
+}
diff --git a/apps/swift/Sources/TodoApp/Static.swift b/apps/swift/Sources/TodoApp/Static.swift
new file mode 100644
index 0000000..95cd0d0
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Static.swift
@@ -0,0 +1,14 @@
+import Hummingbird
+
+extension Assets {
+ /// One embedded file. The CSS and JS change only with a new build, and
+ /// the page links app.css and app.js with the build version, so
+ /// browsers may keep them for a day.
+ static func response(_ text: String, type: String) -> Response {
+ Response(
+ status: .ok,
+ headers: [.contentType: type, .cacheControl: "public, max-age=86400"],
+ body: .init(byteBuffer: ByteBuffer(string: text))
+ )
+ }
+}
diff --git a/apps/swift/Sources/TodoApp/Store.swift b/apps/swift/Sources/TodoApp/Store.swift
new file mode 100644
index 0000000..8659b63
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Store.swift
@@ -0,0 +1,176 @@
+import SQLiteNIO
+
+/// One todo item as the page shows it.
+public struct Todo: Sendable, Equatable {
+ public var id: Int
+ public var title: String
+ public var completed: Bool
+}
+
+/// The app's SQLite database: one file, WAL mode, one connection.
+///
+/// SQLiteNIO runs every call on its thread pool, and the one connection
+/// serialises them. Each write below is a single SQL statement, so two
+/// requests can never interleave half a change, and no transaction has to
+/// span an `await`.
+public final class Store: Sendable {
+ let db: SQLiteConnection
+
+ /// Schema changes, in order. Only ever append to this list; never edit
+ /// or remove an entry, and never drop or rewrite data.
+ /// `PRAGMA user_version` records how many have run.
+ static let migrations: [String] = [
+ """
+ CREATE TABLE lists (
+ id INTEGER PRIMARY KEY,
+ token TEXT NOT NULL UNIQUE,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ );
+ CREATE TABLE todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ list_id INTEGER NOT NULL REFERENCES lists(id),
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ );
+ CREATE INDEX todos_by_list ON todos(list_id, id);
+ """,
+ ]
+
+ init(db: SQLiteConnection) {
+ self.db = db
+ }
+
+ /// Opens (creating if needed) the database at `path` and brings its
+ /// schema up to date.
+ public static func open(path: String) async throws -> Store {
+ let db = try await SQLiteConnection.open(storage: .file(path: path))
+ let store = Store(db: db)
+ do {
+ try await store.prepare()
+ } catch {
+ try? await db.close()
+ throw error
+ }
+ return store
+ }
+
+ func prepare() async throws {
+ _ = try await db.query("PRAGMA journal_mode = WAL")
+ _ = try await db.query("PRAGMA synchronous = NORMAL")
+ _ = try await db.query("PRAGMA busy_timeout = 5000")
+ _ = try await db.query("PRAGMA foreign_keys = ON")
+ let version = try await db.query("PRAGMA user_version").first?.column("user_version")?.integer ?? 0
+ for (index, sql) in Self.migrations.enumerated() where index >= version {
+ // The migration and its version bump commit together, or not at all.
+ try await db.withConnection { conn in
+ _ = try await conn.query("BEGIN IMMEDIATE")
+ do {
+ for statement in sql.split(separator: ";").map({ $0.trimmingWhitespace() }) where !statement.isEmpty {
+ _ = try await conn.query(String(statement))
+ }
+ _ = try await conn.query("PRAGMA user_version = \(index + 1)")
+ _ = try await conn.query("COMMIT")
+ } catch {
+ _ = try? await conn.query("ROLLBACK")
+ throw error
+ }
+ }
+ }
+ }
+
+ public func close() async throws {
+ try await db.close()
+ }
+
+ // MARK: Lists
+
+ /// The list a cookie token names, if the app ever issued it.
+ public func list(token: String) async throws -> Int? {
+ try await db.query("SELECT id FROM lists WHERE token = ?", [.text(token)])
+ .first?.column("id")?.integer
+ }
+
+ /// Records a new, empty list under `token` and returns its id.
+ public func createList(token: String) async throws -> Int {
+ let rows = try await db.query("INSERT INTO lists (token) VALUES (?) RETURNING id", [.text(token)])
+ guard let id = rows.first?.column("id")?.integer else { throw StoreError.noRowReturned }
+ return id
+ }
+
+ // MARK: Todos
+
+ public func todos(list: Int) async throws -> [Todo] {
+ try await db.query(
+ "SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id",
+ [.integer(list)]
+ ).map { row in
+ Todo(
+ id: row.column("id")?.integer ?? 0,
+ title: row.column("title")?.string ?? "",
+ completed: row.column("completed")?.bool ?? false
+ )
+ }
+ }
+
+ public func add(list: Int, title: String) async throws {
+ _ = try await db.query("INSERT INTO todos (list_id, title) VALUES (?, ?)", [.integer(list), .text(title)])
+ }
+
+ /// Flips one item. False if the list has no such item.
+ public func toggle(list: Int, id: Int) async throws -> Bool {
+ try await changed(
+ "UPDATE todos SET completed = 1 - completed WHERE id = ? AND list_id = ? RETURNING id",
+ [.integer(id), .integer(list)]
+ )
+ }
+
+ /// Renames one item. False if the list has no such item.
+ public func rename(list: Int, id: Int, title: String) async throws -> Bool {
+ try await changed(
+ "UPDATE todos SET title = ? WHERE id = ? AND list_id = ? RETURNING id",
+ [.text(title), .integer(id), .integer(list)]
+ )
+ }
+
+ /// Deletes one item. False if the list has no such item.
+ public func destroy(list: Int, id: Int) async throws -> Bool {
+ try await changed(
+ "DELETE FROM todos WHERE id = ? AND list_id = ? RETURNING id",
+ [.integer(id), .integer(list)]
+ )
+ }
+
+ /// Completes every item, or, if every item already is, reopens them all.
+ public func toggleAll(list: Int) async throws {
+ _ = try await db.query(
+ """
+ UPDATE todos
+ SET completed = EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND completed = 0)
+ WHERE list_id = ?1
+ """,
+ [.integer(list)]
+ )
+ }
+
+ public func clearCompleted(list: Int) async throws {
+ _ = try await db.query("DELETE FROM todos WHERE list_id = ? AND completed = 1", [.integer(list)])
+ }
+
+ private func changed(_ sql: String, _ binds: [SQLiteData]) async throws -> Bool {
+ try await !db.query(sql, binds).isEmpty
+ }
+}
+
+enum StoreError: Error {
+ case noRowReturned
+}
+
+extension Substring {
+ func trimmingWhitespace() -> Substring {
+ var s = self
+ while let c = s.first, c.isWhitespace { s.removeFirst() }
+ while let c = s.last, c.isWhitespace { s.removeLast() }
+ return s
+ }
+}
diff --git a/apps/swift/Sources/TodoApp/Visitors.swift b/apps/swift/Sources/TodoApp/Visitors.swift
new file mode 100644
index 0000000..15dd475
--- /dev/null
+++ b/apps/swift/Sources/TodoApp/Visitors.swift
@@ -0,0 +1,77 @@
+import HTTPTypes
+import Hummingbird
+
+/// Gives each visitor their own list through an anonymous cookie.
+///
+/// The cookie holds 128 random bits as 32 hex digits. A value the app
+/// didn't issue, well-formed or not, gets a fresh empty list and a new
+/// cookie, never an error.
+struct Visitors: Sendable {
+ static let cookieName = "todo_list"
+
+ let store: Store
+ /// Adds `Secure` to the cookie. True when served over https.
+ let secure: Bool
+
+ struct Visitor {
+ var list: Int
+ /// Set when this request minted the visitor a new list.
+ var newToken: String?
+ }
+
+ func resolve(_ request: Request) async throws -> Visitor {
+ if let token = request.cookies[Self.cookieName]?.value, Self.isWellFormed(token),
+ let list = try await store.list(token: token)
+ {
+ return Visitor(list: list)
+ }
+ let token = Self.newToken()
+ return Visitor(list: try await store.createList(token: token), newToken: token)
+ }
+
+ func attach(_ visitor: Visitor, to response: inout Response) {
+ guard let token = visitor.newToken else { return }
+ // About 400 days, the longest lifetime browsers honour.
+ var cookie = "\(Self.cookieName)=\(token); Path=/; Max-Age=34560000; HttpOnly; SameSite=Lax"
+ if secure { cookie += "; Secure" }
+ response.headers.append(HTTPField(name: .setCookie, value: cookie))
+ }
+
+ static func newToken() -> String {
+ var rng = SystemRandomNumberGenerator()
+ let bytes = (0..<16).map { _ in UInt8.random(in: .min ... .max, using: &rng) }
+ return bytes.map { byte in
+ let hex = String(byte, radix: 16)
+ return byte < 16 ? "0" + hex : hex
+ }.joined()
+ }
+
+ static func isWellFormed(_ token: String) -> Bool {
+ token.utf8.count == 32 && token.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) }
+ }
+}
+
+/// Refuses a POST whose `Origin` header names another site. A POST with no
+/// `Origin` is allowed; there is no CSRF token.
+struct OriginCheck<Context: RequestContext>: RouterMiddleware {
+ /// The app's own origin, or nil to accept the origin the `Host` header
+ /// implies (an instance started without `CARLOS_HOST`).
+ let origin: String?
+
+ func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
+ if request.method == .post, let sent = request.headers[.origin], !allows(sent, host: request.head.authority) {
+ return Response(
+ status: .forbidden,
+ headers: [.contentType: "text/plain; charset=utf-8"],
+ body: .init(byteBuffer: ByteBuffer(string: "Cross-site form posts are refused.\n"))
+ )
+ }
+ return try await next(request, context)
+ }
+
+ func allows(_ sent: String, host: String?) -> Bool {
+ if let origin { return sent == origin }
+ guard let host else { return false }
+ return sent == "https://\(host)" || sent == "http://\(host)"
+ }
+}
diff --git a/apps/swift/Sources/todo/main.swift b/apps/swift/Sources/todo/main.swift
new file mode 100644
index 0000000..b5cbed6
--- /dev/null
+++ b/apps/swift/Sources/todo/main.swift
@@ -0,0 +1,11 @@
+import Foundation
+import TodoApp
+
+let options: Options
+do {
+ options = try Options(arguments: Array(CommandLine.arguments.dropFirst()), environment: ProcessInfo.processInfo.environment)
+} catch {
+ FileHandle.standardError.write(Data("\(error)\n".utf8))
+ exit(2)
+}
+try await Server.run(options)
diff --git a/apps/swift/Tests/TodoAppTests/RouteTests.swift b/apps/swift/Tests/TodoAppTests/RouteTests.swift
new file mode 100644
index 0000000..8196eff
--- /dev/null
+++ b/apps/swift/Tests/TodoAppTests/RouteTests.swift
@@ -0,0 +1,225 @@
+import Foundation
+import HTTPTypes
+import Hummingbird
+import HummingbirdTesting
+import Testing
+@testable import TodoApp
+
+let origin = "http://127.0.0.1:8080"
+
+/// Runs `body` against the app's router on a fresh database.
+func withApp(_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void) async throws {
+ try await withStore { store, _ in
+ let options = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "unused"], environment: [:])
+ let app = Application(router: Server.router(store: store, options: options, version: "test-version"))
+ try await app.test(.router, body)
+ }
+}
+
+extension TestResponse {
+ var text: String { body.getString(at: body.readerIndex, length: body.readableBytes) ?? "" }
+ var listCookie: String? {
+ guard let header = headers[.setCookie], header.hasPrefix("todo_list=") else { return nil }
+ return String(header.prefix { $0 != ";" })
+ }
+}
+
+extension TestClientProtocol {
+ /// Visits `/` and returns the cookie for a new, empty list.
+ func newVisitor() async throws -> String {
+ try await execute(uri: "/", method: .get) { try #require($0.listCookie) }
+ }
+
+ func post(_ path: String, _ form: String, cookie: String?, headers: HTTPFields = [:]) async throws -> TestResponse {
+ var fields = headers
+ fields[.contentType] = "application/x-www-form-urlencoded"
+ if let cookie { fields[.cookie] = cookie }
+ return try await execute(uri: path, method: .post, headers: fields, body: ByteBuffer(string: form))
+ }
+
+ func get(_ path: String, cookie: String) async throws -> TestResponse {
+ try await execute(uri: path, method: .get, headers: [.cookie: cookie])
+ }
+}
+
+func ids(_ html: String) -> [String] {
+ html.split(separator: "data-id=\"").dropFirst().map { String($0.prefix { $0 != "\"" }) }
+}
+
+@Suite struct RouteTests {
+ @Test func healthAndVersion() async throws {
+ try await withApp { client in
+ try await client.execute(uri: "/healthz", method: .get) { #expect($0.status == .ok) }
+ try await client.execute(uri: "/api/version", method: .get) {
+ #expect($0.status == .ok)
+ #expect($0.text == "test-version")
+ }
+ }
+ }
+
+ @Test func firstVisitGetsAnHttpOnlyLaxCookie() async throws {
+ try await withApp { client in
+ try await client.execute(uri: "/", method: .get) { response in
+ #expect(response.status == .ok)
+ let header = try #require(response.headers[.setCookie])
+ let value = header.prefix { $0 != ";" }.dropFirst("todo_list=".count)
+ #expect(Visitors.isWellFormed(String(value)))
+ #expect(header.contains("HttpOnly"))
+ #expect(header.contains("SameSite=Lax"))
+ #expect(header.contains("Path=/"))
+ #expect(!header.contains("Secure"))
+ #expect(!response.text.contains("class=\"main\""))
+ #expect(response.text.contains("· Swift</p>"))
+ }
+ }
+ }
+
+ @Test(arguments: ["todo_list=%%%", "todo_list=0123456789abcdef0123456789abcdef", "todo_list="])
+ func unknownCookieGetsAFreshList(_ cookie: String) async throws {
+ try await withApp { client in
+ let response = try await client.get("/", cookie: cookie)
+ #expect(response.status == .ok)
+ #expect(response.listCookie != nil)
+ #expect(response.listCookie != cookie)
+ }
+ }
+
+ @Test func addRedirectsToTheFilterAndTrims() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ for (filter, location) in [("all", "/"), ("active", "/active"), ("completed", "/completed"), ("bogus", "/"), ("", "/")] {
+ let response = try await client.post("/todos", "title=++x++&filter=\(filter)", cookie: me)
+ #expect(response.status == .seeOther)
+ #expect(response.headers[.location] == location)
+ }
+ _ = try await client.post("/todos", "title=+++", cookie: me)
+ let page = try await client.get("/", cookie: me).text
+ #expect(page.components(separatedBy: "<label>x</label>").count - 1 == 5)
+ }
+ }
+
+ @Test func titlesAreEscapedAndKeptExactly() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ let title = "<img src=x onerror=alert(1)> & \"q\" naïve 🚀 日本語"
+ let encoded = title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
+ _ = try await client.post("/todos", "title=\(encoded)", cookie: me)
+ let response = try await client.get("/", cookie: me)
+ #expect(response.text.contains("<label>&lt;img src=x onerror=alert(1)&gt; &amp; &quot;q&quot; naïve 🚀 日本語</label>"))
+ #expect(!response.text.contains("<img"))
+ #expect(response.headers[.contentLength] == String(response.body.readableBytes))
+ }
+ }
+
+ @Test func longTitlesWork() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ let long = String(repeating: "é", count: 10_000)
+ _ = try await client.post("/todos", "title=\(long.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", cookie: me)
+ #expect(try await client.get("/", cookie: me).text.contains("<label>\(long)</label>"))
+ }
+ }
+
+ @Test func filtersShowTheirItems() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ for t in ["a", "b", "c"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
+ let all = ids(try await client.get("/", cookie: me).text)
+ _ = try await client.post("/todos/\(all[1])/toggle", "filter=all", cookie: me)
+
+ let active = try await client.get("/active", cookie: me).text
+ #expect(ids(active) == [all[0], all[2]])
+ #expect(active.contains("<a class=\"selected\" href=\"/active\">"))
+ #expect(active.contains("<strong>2</strong> items left"))
+ #expect(active.contains("button class=\"clear-completed\""))
+ #expect(ids(try await client.get("/completed", cookie: me).text) == [all[1]])
+ }
+ }
+
+ @Test func editViewAndEdits() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
+ let all = ids(try await client.get("/", cookie: me).text)
+
+ let edit = try await client.get("/active?edit=\(all[1])", cookie: me).text
+ #expect(edit.contains("<li class=\"editing\" data-id=\"\(all[1])\">"))
+ #expect(edit.contains("<form method=\"post\" action=\"/todos/\(all[1])/edit\"><input type=\"hidden\" name=\"filter\" value=\"active\"><input class=\"edit\" name=\"title\" value=\"b\" autocomplete=\"off\" autofocus>"))
+ #expect(edit.contains("href=\"/active?edit=\(all[0])\""))
+
+ let renamed = try await client.post("/todos/\(all[1])/edit", "title=++bee++&filter=active", cookie: me)
+ #expect(renamed.status == .seeOther)
+ #expect(renamed.headers[.location] == "/active")
+ #expect(try await client.get("/", cookie: me).text.contains("<label>bee</label>"))
+
+ _ = try await client.post("/todos/\(all[1])/edit", "title=+&filter=all", cookie: me)
+ #expect(ids(try await client.get("/", cookie: me).text) == [all[0]])
+ }
+ }
+
+ @Test func toggleAllAndClearCompleted() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
+ _ = try await client.post("/todos/toggle-all", "filter=all", cookie: me)
+ let page = try await client.get("/", cookie: me).text
+ #expect(page.contains("class=\"toggle-all\" type=\"checkbox\" checked"))
+ #expect(page.contains("<strong>0</strong> items left"))
+ _ = try await client.post("/todos/clear-completed", "filter=all", cookie: me)
+ #expect(ids(try await client.get("/", cookie: me).text).isEmpty)
+ }
+ }
+
+ @Test func anotherListsItemIs404AndUnchanged() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ let them = try await client.newVisitor()
+ _ = try await client.post("/todos", "title=mine", cookie: me)
+ let id = ids(try await client.get("/", cookie: me).text)[0]
+ for action in ["toggle", "edit", "destroy"] {
+ #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: them).status == .notFound)
+ #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: nil).status == .notFound)
+ }
+ #expect(try await client.post("/todos/99999/toggle", "filter=all", cookie: me).status == .notFound)
+ #expect(try await client.get("/", cookie: me).text.contains("<li data-id=\"\(id)\">"))
+ #expect(try await client.get("/", cookie: me).text.contains("<label>mine</label>"))
+ }
+ }
+
+ @Test func crossOriginPostsAre403() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ for bad in ["https://evil.example", "null", "http://127.0.0.1:8081", "https://127.0.0.1:8080"] {
+ let response = try await client.post("/todos", "title=evil", cookie: me, headers: [.origin: bad])
+ #expect(response.status == .forbidden)
+ }
+ #expect(try await client.post("/todos", "title=ok", cookie: me, headers: [.origin: origin]).status == .seeOther)
+ #expect(try await client.post("/todos", "title=ok2", cookie: me).status == .seeOther)
+ let page = try await client.get("/", cookie: me).text
+ #expect(!page.contains("evil"))
+ #expect(ids(page).count == 2)
+ }
+ }
+
+ @Test func partialHeaderAnswersWithTheNewListInstead() async throws {
+ try await withApp { client in
+ let me = try await client.newVisitor()
+ let response = try await client.post("/todos", "title=a&filter=active", cookie: me, headers: [partialHeader: "1"])
+ #expect(response.status == .ok)
+ #expect(response.text.hasPrefix("<section class=\"main\">"))
+ #expect(response.text.contains("<a class=\"selected\" href=\"/active\">"))
+ #expect(!response.text.contains("<html"))
+ }
+ }
+
+ @Test func staticFilesAreServedVerbatim() async throws {
+ try await withApp { client in
+ try await client.execute(uri: "/static/index.css", method: .get) { response in
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == "text/css; charset=utf-8")
+ #expect(response.text == Assets.indexCSS)
+ }
+ try await client.execute(uri: "/static/base.css", method: .get) { #expect($0.text == Assets.baseCSS) }
+ }
+ }
+}
diff --git a/apps/swift/Tests/TodoAppTests/StoreTests.swift b/apps/swift/Tests/TodoAppTests/StoreTests.swift
new file mode 100644
index 0000000..fb95e56
--- /dev/null
+++ b/apps/swift/Tests/TodoAppTests/StoreTests.swift
@@ -0,0 +1,120 @@
+import Foundation
+import Testing
+@testable import TodoApp
+
+/// A store on a fresh database file, removed afterwards.
+func withStore<T>(_ body: (Store, String) async throws -> T) async throws -> T {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent("todo-swift-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let path = dir.appendingPathComponent("app.db").path
+ let store = try await Store.open(path: path)
+ do {
+ let result = try await body(store, path)
+ try await store.close()
+ return result
+ } catch {
+ try? await store.close()
+ throw error
+ }
+}
+
+@Suite struct StoreTests {
+ @Test func usesWALAndRecordsTheSchemaVersion() async throws {
+ try await withStore { store, path in
+ #expect(try await store.db.query("PRAGMA journal_mode").first?.column("journal_mode")?.string == "wal")
+ let version = try await store.db.query("PRAGMA user_version").first?.column("user_version")?.integer
+ #expect(version == Store.migrations.count)
+ // Opening again runs nothing twice and keeps the data.
+ let list = try await store.createList(token: Visitors.newToken())
+ try await store.add(list: list, title: "kept")
+ let again = try await Store.open(path: path)
+ #expect(try await again.todos(list: list).map(\.title) == ["kept"])
+ try await again.close()
+ }
+ }
+
+ @Test func toggleAllCompletesEverythingThenReopensEverything() async throws {
+ try await withStore { store, _ in
+ let list = try await store.createList(token: Visitors.newToken())
+ for title in ["a", "b", "c"] { try await store.add(list: list, title: title) }
+ let first = try await store.todos(list: list)[0].id
+ #expect(try await store.toggle(list: list, id: first))
+
+ try await store.toggleAll(list: list)
+ #expect(try await store.todos(list: list).allSatisfy(\.completed))
+ try await store.toggleAll(list: list)
+ #expect(try await store.todos(list: list).allSatisfy { !$0.completed })
+ }
+ }
+
+ @Test func listsAreSeparate() async throws {
+ try await withStore { store, _ in
+ let mine = try await store.createList(token: Visitors.newToken())
+ let theirs = try await store.createList(token: Visitors.newToken())
+ try await store.add(list: mine, title: "mine")
+ let id = try await store.todos(list: mine)[0].id
+
+ #expect(try await store.toggle(list: theirs, id: id) == false)
+ #expect(try await store.rename(list: theirs, id: id, title: "x") == false)
+ #expect(try await store.destroy(list: theirs, id: id) == false)
+ try await store.toggleAll(list: theirs)
+ try await store.clearCompleted(list: theirs)
+ #expect(try await store.todos(list: mine) == [Todo(id: id, title: "mine", completed: false)])
+ #expect(try await store.todos(list: theirs).isEmpty)
+ }
+ }
+
+ @Test func clearCompletedKeepsActiveItems() async throws {
+ try await withStore { store, _ in
+ let list = try await store.createList(token: Visitors.newToken())
+ for title in ["a", "b", "c"] { try await store.add(list: list, title: title) }
+ let b = try await store.todos(list: list)[1].id
+ _ = try await store.toggle(list: list, id: b)
+ try await store.clearCompleted(list: list)
+ #expect(try await store.todos(list: list).map(\.title) == ["a", "c"])
+ }
+ }
+}
+
+@Suite struct OptionsTests {
+ @Test func socketMode() throws {
+ let o = try Options(arguments: ["--socket", "/run/a.sock", "--db", "/d/app.db"], environment: ["CARLOS_HOST": "todo-swift.bfj.oncarlos.com"])
+ #expect(o.address == .socket("/run/a.sock"))
+ #expect(o.origin == "https://todo-swift.bfj.oncarlos.com")
+ #expect(o.servedOverHTTPS)
+ }
+
+ @Test func listenMode() throws {
+ let o = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "x.db"], environment: [:])
+ #expect(o.address == .listen(host: "127.0.0.1", port: 8080))
+ #expect(o.origin == "http://127.0.0.1:8080")
+ #expect(!o.servedOverHTTPS)
+ }
+
+ @Test(arguments: [
+ ["--listen", "0.0.0.0:8080", "--db", "x.db"],
+ ["--listen", ":8080", "--db", "x.db"],
+ ["--listen", "127.0.0.1:8080"],
+ ["--socket", "a.sock", "--listen", "127.0.0.1:1", "--db", "x.db"],
+ ["--db"],
+ ])
+ func refusesBadArguments(_ arguments: [String]) {
+ #expect(throws: Options.UsageError.self) { try Options(arguments: arguments, environment: [:]) }
+ }
+}
+
+@Suite struct AssetTests {
+ static let appDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+
+ @Test(arguments: [
+ ("../../shared/base.css", Assets.baseCSS),
+ ("../../shared/index.css", Assets.indexCSS),
+ ("static/app.css", Assets.appCSS),
+ ("static/app.js", Assets.appJS),
+ ])
+ func embeddedFilesMatchTheirSources(_ path: String, _ embedded: String) throws {
+ let source = try Data(contentsOf: Self.appDir.appendingPathComponent(path))
+ #expect(Data(embedded.utf8) == source, "run tools/embed.sh")
+ }
+}
diff --git a/apps/swift/static/app.css b/apps/swift/static/app.css
new file mode 100644
index 0000000..7c772a5
--- /dev/null
+++ b/apps/swift/static/app.css
@@ -0,0 +1,61 @@
+/* The controls a page without JavaScript uses. app.js's inline starter puts
+ the class "js" on <html>, and then they're hidden: the checkboxes,
+ double-click and blur take over, exactly as in TodoMVC. */
+.js .nojs {
+ display: none !important;
+}
+
+/* Forms that only wrap a control take no part in layout. */
+form.inline {
+ display: contents;
+}
+
+/* Without JavaScript, a submit button lies exactly over each checkbox, so
+ the page looks the same either way. */
+.todo-list li .view > button[data-action="toggle"] {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+.main button[data-action="toggle-all"] {
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+/* Without JavaScript there's no double-click, so each item has an edit link
+ beside the delete button. */
+.todo-list li .view > a[data-action="edit"] {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 55px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 20px;
+ line-height: 40px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .view > a[data-action="edit"]:hover,
+.todo-list li .view > a[data-action="edit"]:focus {
+ color: #484848;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 90px;
+}
diff --git a/apps/swift/static/app.js b/apps/swift/static/app.js
new file mode 100644
index 0000000..4a00697
--- /dev/null
+++ b/apps/swift/static/app.js
@@ -0,0 +1,77 @@
+// TodoMVC behaviour on top of the server-rendered page. Every change is
+// still the page's own form post; this sends it with fetch and swaps in
+// the new list instead of loading the page again.
+
+const app = document.querySelector('.todoapp');
+
+// Sends a form and replaces section.main and footer.footer with what the
+// server answers. Anything unexpected falls back to a full page load.
+async function send(form) {
+ if (form.dataset.sending) return;
+ form.dataset.sending = '1';
+ try {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ headers: { 'X-Todo-Partial': '1' },
+ body: new URLSearchParams(new FormData(form)),
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ swap(await res.text());
+ if (form.closest('.header')) form.reset();
+ } catch {
+ location.reload();
+ } finally {
+ delete form.dataset.sending;
+ }
+}
+
+function swap(html) {
+ const tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove());
+ app.append(tpl.content);
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ send(e.target);
+});
+
+app.addEventListener('change', (e) => {
+ const li = e.target.closest('li[data-id]');
+ if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`));
+ if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form);
+});
+
+// Editing: double-click opens the editor, Enter or blur saves, Escape
+// cancels. An empty title deletes the item (the server decides that).
+function startEditing(li) {
+ li.classList.add('editing');
+ const input = li.querySelector('.edit');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+}
+
+app.addEventListener('dblclick', (e) => {
+ const li = e.target.closest('.todo-list li');
+ if (li && e.target.matches('.view label')) startEditing(li);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const input = e.target;
+ input.value = input.defaultValue;
+ input.closest('li').classList.remove('editing');
+ input.blur();
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (li.classList.contains('editing')) send(e.target.form);
+});
+
+// The server's edit view (?edit=<id>) opens with that item's editor; put
+// the cursor at the end, as double-click does.
+const editing = app.querySelector('.todo-list li.editing');
+if (editing) startEditing(editing);
diff --git a/apps/swift/tools/embed.sh b/apps/swift/tools/embed.sh
new file mode 100755
index 0000000..9609bc2
--- /dev/null
+++ b/apps/swift/tools/embed.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Writes Sources/TodoApp/Assets.swift: the shared TodoMVC CSS and this app's
+# static files, embedded byte for byte so the binary is the whole app.
+# tools/embed.sh <shared-dir> <static-dir> <out.swift>
+set -euo pipefail
+shared=$1 static=$2 out=$3
+tmp=$(mktemp "$out.XXXXXX")
+trap 'rm -f "$tmp"' EXIT
+literal() {
+ if grep -q '"""#' "$1"; then echo "embed.sh: $1 contains \"\"\"#" >&2; exit 1; fi
+ printf ' static let %s = #"""\n' "$2"
+ cat "$1"
+ # A raw multi-line literal drops the newline before its closing
+ # delimiter, so add one to keep the file's own final newline.
+ printf '\n"""#\n\n'
+}
+{
+ printf '// GENERATED by tools/embed.sh from shared/ and static/. Do not edit.\n\n'
+ printf 'enum Assets {\n'
+ literal "$shared/base.css" baseCSS
+ literal "$shared/index.css" indexCSS
+ literal "$static/app.css" appCSS
+ literal "$static/app.js" appJS
+ printf '}\n'
+} > "$tmp"
+if cmp -s "$tmp" "$out"; then exit 0; fi
+mv "$tmp" "$out"
+trap - EXIT