carlos / todomvc-bakeoff Public
feat: swift builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 files changed,
+2482
−0
apps/swift/.gitignore+6 −0apps/swift/AGENTS.md+60 −0apps/swift/DEPS+3 −0apps/swift/Makefile+75 −0apps/swift/Package.resolved+231 −0apps/swift/Package.swift+30 −0apps/swift/README.md+114 −0apps/swift/STATUS.md+48 −0apps/swift/Sources/TodoApp/Assets.swift+701 −0apps/swift/Sources/TodoApp/Page.swift+159 −0apps/swift/Sources/TodoApp/Routes.swift+139 −0apps/swift/Sources/TodoApp/Server.swift+111 −0apps/swift/Sources/TodoApp/Static.swift+14 −0apps/swift/Sources/TodoApp/Store.swift+176 −0apps/swift/Sources/TodoApp/Visitors.swift+77 −0apps/swift/Sources/todo/main.swift+18 −0apps/swift/Tests/TodoAppTests/RouteTests.swift+225 −0apps/swift/Tests/TodoAppTests/StoreTests.swift+120 −0apps/swift/static/app.css+61 −0apps/swift/static/app.js+86 −0apps/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/AGENTS.md b/apps/swift/AGENTS.md| new file mode 100644 |
| index 0000000..b29592c |
| --- /dev/null |
| +++ b/apps/swift/AGENTS.md |
| @@ -0,0 +1,60 @@ |
| +# 🤖 AGENTS.md: todo-swift |
| + |
| +The CARLOS bakeoff's TodoMVC, written in Swift. |
| + |
| +## The one rule |
| + |
| +**The page works with JavaScript off.** Every action is a plain HTML form |
| +that posts and answers 303; JavaScript only makes the same requests |
| +without a page load. If a change works only with JavaScript, it's a bug. |
| + |
| +Everything else follows from `BRIEF.md` and `CONTRACT.md` at the repo |
| +root, which are binding. Where this file and the contract disagree, the |
| +contract wins. |
| + |
| +## Tenancy declaration |
| + |
| +``` |
| +Tenancy declaration: todo-swift, 2026-10-23, the bakeoff brief |
| +One instance is: the whole app (person : instance = N:1) |
| +Why this unit: a todo list is tiny and anonymous; nothing is gained |
| + by an instance per visitor, and the bakeoff measures |
| + one process |
| +Reached through: model A (one instance), no sign-in; each visitor's |
| + list is kept apart by an anonymous random cookie |
| + (128 bits, HttpOnly, SameSite=Lax, Secure on https) |
| +Central app: none |
| +Workloads and class: instance = C (host-sighted) |
| +Declared residue: everything: todo titles and completed flags are |
| + stored as plain text in SQLite, alongside each list's |
| + cookie value and creation time |
| +Keys live: n/a |
| +Hosting: Carloku (account bfj, app todo-swift) |
| +Licence class: not published yet |
| +Benefits claimed: replication and hibernation from the platform; |
| + one static binary, one SQLite file |
| +Costs accepted: the host (and anyone holding a replica) can read every |
| + list; a lost cookie is a lost list, and there's no |
| + recovery; lists are not erased when abandoned |
| +Waiting on: nothing |
| +``` |
| + |
| +Class C is chosen on purpose: the bakeoff compares languages, not |
| +encryption, and a todo list holds nothing a visitor was promised would |
| +stay private. The page says nothing to suggest otherwise. |
| + |
| +## Working rules |
| + |
| +- Work only in `apps/swift/`. The rest of the repo is read-only. |
| +- One static linux-arm64 binary: `make build-arm64`. No shared library |
| + beyond libc (the Static Linux SDK links musl in). |
| +- SQLite in WAL mode at `--db`. Migrations only add; never drop or |
| + rewrite data. They're numbered in `Store.swift` and tracked with |
| + `PRAGMA user_version`. |
| +- Every database write is a single SQL statement, so requests that share |
| + the one connection can't interleave half a change. |
| +- Bind `127.0.0.1` only when listening on TCP. Scratch goes in |
| + `mktemp -d -p "$TMPDIR"`. Stop what you start. |
| +- `make test` and the shared suite (`scripts/test-app.sh`) pass before a |
| + deploy. |
| +- Commits end with a `Co-Authored-By:` trailer naming the model. |
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..c8854fa |
| --- /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.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.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; } |
| + |
| +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..7b4b4fb |
| --- /dev/null |
| +++ b/apps/swift/Package.swift |
| @@ -0,0 +1,30 @@ |
| +// 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", 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"), |
| + ], |
| + 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/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/STATUS.md b/apps/swift/STATUS.md| new file mode 100644 |
| index 0000000..a382109 |
| --- /dev/null |
| +++ b/apps/swift/STATUS.md |
| @@ -0,0 +1,48 @@ |
| +LIVE https://todo-swift.bfj.oncarlos.com 354c88a |
| + |
| +## Checked |
| + |
| +- `make test`: 21 Swift Testing tests pass. |
| +- Shared suite against `dist/app --listen 127.0.0.1:<port>`: 67 passed, |
| + 3 skipped (the suite's own skips: blur and Escape need JavaScript, and |
| + the screenshot is compared with JavaScript on only). |
| +- `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz` and |
| + `/api/version` 200 (`354c88a`), clean exit on SIGTERM. |
| +- `make deploy`: shipped and promoted `354c88a` to `edge`, and |
| + `/api/version` on the live host reports it. |
| +- Shared suite against `https://todo-swift.bfj.oncarlos.com`: 67 passed, |
| + 3 skipped. |
| + |
| +## Notes |
| + |
| +- Stack: Hummingbird 2 and SQLiteNIO (which compiles SQLite 3.54 in), |
| + built with the Swift 6.2 Static Linux SDK. The arm64 binary is static, |
| + stripped, and 22 MB. Turning off Hummingbird's `FullFoundation` trait |
| + and importing only `FoundationEssentials` took it down from 66 MB. |
| +- A cold `make build-arm64` takes a few minutes here; an incremental one |
| + after `make bump` recompiles one file. |
| +- Nothing in the contract needed a deviation. |
| + |
| +## Review round |
| + |
| +Both findings are fixed in `static/app.js` (commit `354c88a`): |
| + |
| +1. **Typing during an add.** The form is read and the new-todo input |
| + cleared when the form is submitted, not when the answer arrives. A |
| + second Enter while the first add is still pending is queued, not |
| + dropped. Each edit is saved once, guarded per item, instead of |
| + blocking the whole form. |
| +2. **Stale snapshots.** Actions go through one promise queue, so they |
| + reach the server and come back in the order they were made, and the |
| + last swap is always the newest state. |
| + |
| +Checked by hand in Chromium with Playwright, with the first response held |
| +back 800 ms. Adding "first", then "second", then typing "third" left both |
| +items saved and "third" still in the input. Toggling two items quickly |
| +left both completed, with "0 items left". The shared suite passes |
| +locally (67 passed, 3 skipped) and against the live host (67 passed, |
| +3 skipped). |
| + |
| +The first `make deploy` was refused with "too many uploads" (a |
| +platform-side rate limit). My retry loop then shipped the same |
| +`354c88a` build several more times, which was harmless. |
diff --git a/apps/swift/Sources/TodoApp/Assets.swift b/apps/swift/Sources/TodoApp/Assets.swift| new file mode 100644 |
| index 0000000..39d1a9d |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Assets.swift |
| @@ -0,0 +1,701 @@ |
| +// 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'); |
| + |
| +// Actions run one at a time, in the order they were made, so each answer |
| +// is the newest state when it's swapped in and an older one can never |
| +// overwrite it. |
| +let queue = Promise.resolve(); |
| + |
| +// Sends a form and replaces section.main and footer.footer with what the |
| +// server answers. The form is read when it's submitted, so the visitor can |
| +// carry on typing. Anything unexpected falls back to a full page load. |
| +function send(form) { |
| + const action = form.action; |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + const res = await fetch(action, { |
| + method: 'POST', |
| + headers: { 'X-Todo-Partial': '1' }, |
| + body, |
| + }); |
| + if (!res.ok) throw new Error(`${res.status}`); |
| + swap(await res.text()); |
| + }).catch(() => location.reload()); |
| +} |
| + |
| +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(); |
| + const form = e.target; |
| + send(form); |
| + // The new-todo input is ready for the next title straight away. |
| + if (form.closest('.header')) form.reset(); |
| + // An edit is saved once: the blur that follows doesn't save it again. |
| + const li = form.closest('.todo-list li'); |
| + if (li) li.dataset.saved = '1'; |
| +}); |
| + |
| +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') || li.dataset.saved) return; |
| + li.dataset.saved = '1'; |
| + 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 += "&" |
| + case "<": out += "<" |
| + case ">": out += ">" |
| + case "\"": out += """ |
| + case "'": out += "'" |
| + 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..d67a226 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Server.swift |
| @@ -0,0 +1,111 @@ |
| +import FoundationEssentials |
| +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..71eb747 |
| --- /dev/null |
| +++ b/apps/swift/Sources/todo/main.swift |
| @@ -0,0 +1,18 @@ |
| +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 { |
| + let message = Array("\(error)\n".utf8) |
| + _ = write(2, message, message.count) |
| + 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><img src=x onerror=alert(1)> & "q" 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..5f93e67 |
| --- /dev/null |
| +++ b/apps/swift/static/app.js |
| @@ -0,0 +1,86 @@ |
| +// 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'); |
| + |
| +// Actions run one at a time, in the order they were made, so each answer |
| +// is the newest state when it's swapped in and an older one can never |
| +// overwrite it. |
| +let queue = Promise.resolve(); |
| + |
| +// Sends a form and replaces section.main and footer.footer with what the |
| +// server answers. The form is read when it's submitted, so the visitor can |
| +// carry on typing. Anything unexpected falls back to a full page load. |
| +function send(form) { |
| + const action = form.action; |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + const res = await fetch(action, { |
| + method: 'POST', |
| + headers: { 'X-Todo-Partial': '1' }, |
| + body, |
| + }); |
| + if (!res.ok) throw new Error(`${res.status}`); |
| + swap(await res.text()); |
| + }).catch(() => location.reload()); |
| +} |
| + |
| +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(); |
| + const form = e.target; |
| + send(form); |
| + // The new-todo input is ready for the next title straight away. |
| + if (form.closest('.header')) form.reset(); |
| + // An edit is saved once: the blur that follows doesn't save it again. |
| + const li = form.closest('.todo-list li'); |
| + if (li) li.dataset.saved = '1'; |
| +}); |
| + |
| +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') || li.dataset.saved) return; |
| + li.dataset.saved = '1'; |
| + 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 |