swift: README, drop full Foundation (66 MB to 22 MB), atomic dist copies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 407bd88ef892aa04be8f3afe7d15e93ea6ebd3a9 parent d5e64b2
5 files changed, +128 −7
  • apps/swift/Makefile +2 −2
  • apps/swift/Package.swift +2 −2
  • apps/swift/README.md +114 −0
  • apps/swift/Sources/TodoApp/Server.swift +1 −1
  • apps/swift/Sources/todo/main.swift +9 −2
diff --git a/apps/swift/Makefile b/apps/swift/Makefile
index 2aa7e51..c8854fa 100644
--- a/apps/swift/Makefile
+++ b/apps/swift/Makefile
@@ -28,12 +28,12 @@ generate:
build: generate
$(SWIFT) build -c release $(SWIFTPM)
@mkdir -p dist
- cp .cache/build/release/todo dist/app
+ cp .cache/build/release/todo dist/app.new && mv dist/app.new 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
+ cp .cache/build/$(ARM64_SDK)/release/todo dist/app-linux-arm64.new && mv dist/app-linux-arm64.new 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; }
diff --git a/apps/swift/Package.swift b/apps/swift/Package.swift
index 53cd5c5..7b4b4fb 100644
--- a/apps/swift/Package.swift
+++ b/apps/swift/Package.swift
@@ -1,11 +1,11 @@
-// swift-tools-version:6.0
+// swift-tools-version:6.1
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/hummingbird-project/hummingbird.git", from: "2.28.0", traits: []),
.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"),
],
diff --git a/apps/swift/README.md b/apps/swift/README.md
new file mode 100644
index 0000000..b696c6c
--- /dev/null
+++ b/apps/swift/README.md
@@ -0,0 +1,114 @@
+# 🤖 todo-swift
+
+TodoMVC for the CARLOS bakeoff, written in Swift. It's live at
+[https://todo-swift.bfj.oncarlos.com](https://todo-swift.bfj.oncarlos.com).
+
+The server renders every page, and every action is an ordinary HTML form,
+so the app works with JavaScript off. With JavaScript on, a small ES module
+sends the same forms with `fetch` and swaps in the new list, so it behaves
+like the TodoMVC reference apps. `AGENTS.md` has the rule the app lives by
+and its tenancy declaration; `CONTRACT.md` at the repo root has the routes
+and markup it shares with the other entries.
+
+## The stack, and why
+
+| Piece | Choice | Why |
+|---|---|---|
+| Language | Swift 6.2, strict concurrency | The setup under test. The Static Linux SDK links musl and the Swift runtime into one static binary |
+| HTTP server | [Hummingbird 2](https://github.com/hummingbird-project/hummingbird) | The lightweight server framework on SwiftNIO, async/await throughout. It binds a unix socket directly (`BindAddress.unixDomainSocket`), and its `runService()` handles SIGTERM with a graceful shutdown, which is exactly the platform's process contract. Vapor would also work, but it brings far more than a nine-route app needs |
+| SQLite | [SQLiteNIO](https://github.com/vapor/sqlite-nio) | It compiles SQLite (3.54) from source as part of the package, so nothing depends on a system `libsqlite3`, which the Static Linux SDK doesn't have. GRDB is the nicer API, but it expects a system SQLite on Linux |
+| Header types | [swift-http-types](https://github.com/apple/swift-http-types) | Already under Hummingbird; listed because the app names `HTTPField` itself |
+| HTML | String building in `Page.swift`, one `escape` function | Three small templates don't need a template engine. Every visitor-supplied string goes through `escape` |
+| CSS and JS | Embedded in the binary by `tools/embed.sh` | One file to ship. A test checks the embedded copies match `shared/` and `static/` byte for byte |
+| Tests | Swift Testing, with `HummingbirdTesting` driving the router | No server or port needed for the unit tests |
+| Arguments | Parsed by hand | Three flags didn't justify swift-argument-parser |
+
+Two size decisions: Hummingbird's default `FullFoundation` trait is
+switched off and the app only imports `FoundationEssentials`, so the ICU
+data that full Foundation carries stays out. That took the arm64 binary
+from 66 MB to 22 MB. The shipped binary is also stripped.
+
+### How it fits together
+
+- `Sources/todo/main.swift` reads the flags and starts the server.
+- `Server.swift` parses `--socket`/`--listen`/`--db`, works out the app's
+ own origin (`https://$CARLOS_HOST` on a socket, `http://127.0.0.1:<port>`
+ with `--listen`) and runs Hummingbird until SIGTERM.
+- `Routes.swift` holds every route, including the `GET /` handler.
+- `Visitors.swift` gives each visitor a list through a `todo_list` cookie
+ (128 random bits, `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` on a
+ socket, where the edge serves https). It also holds the `Origin` check
+ that answers 403 to cross-site posts.
+- `Store.swift` is the SQLite layer: WAL mode, append-only migrations
+ tracked by `PRAGMA user_version`, and one SQL statement per change. An
+ action on an item that isn't in the caller's list changes no row and
+ answers 404.
+- `Page.swift` renders the page from `shared/reference.html`'s markup.
+- `static/app.js` is the JavaScript; `static/app.css` places the
+ no-JavaScript controls over the checkboxes they stand in for and hides
+ them when JavaScript runs.
+
+With JavaScript, an action sends the header `X-Todo-Partial: 1` and gets
+back the new `section.main` and `footer.footer` (200) instead of a 303.
+Without that header nothing changes.
+
+## Build, run, test, deploy
+
+Put the toolchain on `PATH` first: `source ../../scripts/env.sh`. The
+Makefile does that itself.
+
+| Command | Does |
+|---|---|
+| `make build` | Builds for this machine into `dist/app` (dynamically linked against the local Swift runtime) |
+| `make build-arm64` | Builds the shipped static binary, `dist/app-linux-arm64` |
+| `make test` | Runs the unit tests |
+| `make run` | Runs `dist/app` on `127.0.0.1:8080` (`PORT=` to change) |
+| `make clean` | Removes `dist/` |
+| `make clean-deps` | Also removes every compiled artifact under `.cache/build`, keeping the downloaded sources |
+| `make bump` / `make unbump` | Changes a string constant in `Routes.swift`, forcing that file to recompile |
+| `make deploy` | Deploys `dist/app-linux-arm64` to Carloku |
+
+SwiftPM's checkouts, repository cache and build output all live in
+`.cache/` (`--scratch-path` and `--cache-path`), so nothing is shared with
+other projects.
+
+The build version is the commit's short SHA. `make` writes it into
+`Sources/TodoApp/Version.swift` (git-ignored), with `-dirty` added when
+`apps/swift` has uncommitted changes. `make deploy` refuses a dirty build,
+or a binary built at another commit.
+
+The shared behaviour suite:
+
+```
+make build
+dist/app --listen 127.0.0.1:8080 --db $TMPDIR/swift.db &
+../../scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/swift-suite.json
+```
+
+Before a deploy, `../../scripts/boot-check.sh dist/app-linux-arm64` runs
+the arm64 binary under qemu on a unix socket and checks `/healthz`,
+`/api/version` and a clean exit on SIGTERM. Then:
+
+```
+git commit ...
+make build-arm64 deploy MSG="what changed"
+```
+
+The app was created on Carloku once with:
+
+```
+carlos apps create --account bfj --app todo-swift --build-probe /api/version
+carlos instances enable --account bfj --app todo-swift
+carlos instances create --account bfj --app todo-swift --host todo-swift.bfj.oncarlos.com --channel edge
+```
+
+## Trade-offs
+
+- Trust class C: titles are stored as plain text, and anyone holding the
+ database or a replica can read every list. See `AGENTS.md`.
+- A list belongs to a cookie. Clear it and the list is gone for good.
+ Abandoned lists are never erased.
+- Every visit without a valid cookie creates a list row, crawlers
+ included. The rows are tiny, and there's no cleanup yet.
+- The one SQLite connection serialises all queries. That's plenty for a
+ todo list. A busier app would add a read pool.
diff --git a/apps/swift/Sources/TodoApp/Server.swift b/apps/swift/Sources/TodoApp/Server.swift
index 88ee930..d67a226 100644
--- a/apps/swift/Sources/TodoApp/Server.swift
+++ b/apps/swift/Sources/TodoApp/Server.swift
@@ -1,4 +1,4 @@
-import Foundation
+import FoundationEssentials
import Hummingbird
import Logging
diff --git a/apps/swift/Sources/todo/main.swift b/apps/swift/Sources/todo/main.swift
index b5cbed6..71eb747 100644
--- a/apps/swift/Sources/todo/main.swift
+++ b/apps/swift/Sources/todo/main.swift
@@ -1,11 +1,18 @@
-import Foundation
+import FoundationEssentials
import TodoApp
+#if canImport(Musl)
+import Musl
+#elseif canImport(Glibc)
+import Glibc
+#endif
+
let options: Options
do {
options = try Options(arguments: Array(CommandLine.arguments.dropFirst()), environment: ProcessInfo.processInfo.environment)
} catch {
- FileHandle.standardError.write(Data("\(error)\n".utf8))
+ let message = Array("\(error)\n".utf8)
+ _ = write(2, message, message.count)
exit(2)
}
try await Server.run(options)