feat: nim builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev fcc06f68bebed80d46a9c7182c4af6aedeaf17c1 parent 63ecfdfparent 1a949e0
14 files changed, +1727 −0
  • apps/nim/AGENTS.md +35 −0
  • apps/nim/DEPS +2 −0
  • apps/nim/Makefile +77 −0
  • apps/nim/README.md +97 −0
  • apps/nim/STATUS.md +90 −0
  • apps/nim/src/app.nim +256 −0
  • apps/nim/src/httpserver.nim +454 −0
  • apps/nim/src/page.nimf +75 −0
  • apps/nim/src/store.nim +208 −0
  • apps/nim/src/views.nim +30 −0
  • apps/nim/static/app.css +62 −0
  • apps/nim/static/app.js +124 −0
  • apps/nim/tests/test_app.nim +204 −0
  • apps/nim/todo.nimble +13 −0
diff --git a/apps/nim/AGENTS.md b/apps/nim/AGENTS.md
new file mode 100644
index 0000000..467cb44
--- /dev/null
+++ b/apps/nim/AGENTS.md
@@ -0,0 +1,35 @@
+# 🤖 AGENTS.md: todo-nim
+
+The bakeoff's TodoMVC, written in Nim, deployed to Carloku as `todo-nim`
+on account `bfj` (`https://todo-nim.bfj.oncarlos.com`).
+
+## The one rule
+
+**The page works with JavaScript off.** Every action is a plain HTML form
+that posts and gets a 303 back. JavaScript only makes the same requests
+without a page load and swaps in the HTML the server renders. If a change
+needs JavaScript to function, it's the wrong change.
+
+## Tenancy
+
+- **One instance for the whole app.** Every visitor shares one process
+ and one SQLite file.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first visit gets a random 128-bit value in an `HttpOnly; SameSite=Lax`
+ cookie (`Secure` on Carloku). Every query is scoped to that list, and an
+ id from someone else's list answers 404. A cookie the app doesn't
+ recognise gets a fresh, empty list.
+- **No sign-in.** Losing the cookie means losing the list.
+- **Trust class C.** Todo titles are stored as plain text in SQLite, not
+ encrypted. That's a deliberate choice: the data is low-value, has no
+ owner identity attached, and a todo list doesn't justify key handling.
+
+## Working here
+
+- Source is in `src/`. `make test` runs the unit tests, `make build` the
+ local binary; the README has the rest.
+- The CSS is `shared/base.css` and `shared/index.css`, embedded at compile
+ time and served byte for byte. Don't copy or edit them here.
+- Migrations only add. Append a new entry to `migrations` in
+ `src/store.nim`; never edit or remove one that has shipped.
+- Bind `127.0.0.1` only. Scratch goes in `$TMPDIR`.
diff --git a/apps/nim/DEPS b/apps/nim/DEPS
new file mode 100644
index 0000000..e221fdb
--- /dev/null
+++ b/apps/nim/DEPS
@@ -0,0 +1,2 @@
+db_connector
+sqlite
diff --git a/apps/nim/Makefile b/apps/nim/Makefile
new file mode 100644
index 0000000..bb223ef
--- /dev/null
+++ b/apps/nim/Makefile
@@ -0,0 +1,77 @@
+# TodoMVC in Nim. See README.md.
+SHELL := bash
+REPO := $(abspath ../..)
+ZIGCC := $(REPO)/scripts/zigcc-aarch64-musl
+CACHE := $(CURDIR)/.cache
+
+VERSION ?= $(shell git rev-parse --short HEAD)
+SQLITE := sqlite-amalgamation-3540100
+SQLITE_URL := https://sqlite.org/2026/$(SQLITE).zip
+SQLITE_SHA256 := 0a9c8e34352dad46006aa82836d5383bff9dc6bc9ce5a60553c05aa30e6ad1f9
+SQLITE_C := $(CACHE)/sqlite/sqlite3.c
+NIMBLE_DIR := $(CACHE)/nimble
+DEPS_STAMP := $(NIMBLE_DIR)/.installed
+
+# zig cc (the arm64 C compiler) keeps compiled musl, compiler-rt and every
+# object it builds in its caches; keep them here so clean-deps reaches them.
+export ZIG_GLOBAL_CACHE_DIR := $(CACHE)/zig-global
+export ZIG_LOCAL_CACHE_DIR := $(CACHE)/zig-local
+
+# Every library is compiled from source into a nimcache under .cache/.
+NIMFLAGS := --hints:off -d:release --opt:speed \
+ --noNimblePath $$(for p in $(NIMBLE_DIR)/pkgs2/*/; do echo --path:$$p; done) \
+ --dynlibOverride:sqlite3 -d:sqliteSource=$(SQLITE_C) \
+ -d:appVersion=$(VERSION)
+ARM64FLAGS := --cpu:arm64 --os:linux --cc:clang \
+ --clang.exe=$(ZIGCC) --clang.linkerexe=$(ZIGCC) --passL:-static --passL:-s
+
+SOURCES := $(wildcard src/*.nim src/*.nimf static/*) $(REPO)/shared/base.css $(REPO)/shared/index.css
+
+.PHONY: build build-arm64 test clean clean-deps deps bump unbump deploy
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+deps: $(DEPS_STAMP) $(SQLITE_C)
+
+dist/app: $(SOURCES) deps .FORCE
+ nim c $(NIMFLAGS) --nimcache:$(CACHE)/nimcache-host -o:$@ src/app.nim
+
+dist/app-linux-arm64: $(SOURCES) deps .FORCE
+ nim c $(NIMFLAGS) $(ARM64FLAGS) --nimcache:$(CACHE)/nimcache-arm64 -o:$@ src/app.nim
+
+test: deps
+ nim c -r $(NIMFLAGS) -d:testing --nimcache:$(CACHE)/nimcache-test -o:$(CACHE)/test_app tests/test_app.nim
+
+$(DEPS_STAMP): todo.nimble
+ source $(REPO)/scripts/env.sh && nimble --nimbleDir:$(NIMBLE_DIR) install --depsOnly -y
+ touch $@
+
+$(SQLITE_C):
+ mkdir -p $(CACHE)/dl $(CACHE)/sqlite
+ [ -f $(CACHE)/dl/$(SQLITE).zip ] || curl -sSfL -o $(CACHE)/dl/$(SQLITE).zip $(SQLITE_URL)
+ echo "$(SQLITE_SHA256) $(CACHE)/dl/$(SQLITE).zip" | sha256sum -c --quiet
+ unzip -q -o -j $(CACHE)/dl/$(SQLITE).zip '$(SQLITE)/sqlite3.c' '$(SQLITE)/sqlite3.h' -d $(CACHE)/sqlite
+ touch $@
+
+clean:
+ rm -rf dist
+
+# Compiled output only: downloaded sources (.cache/nimble, .cache/dl,
+# .cache/sqlite) stay, so the next build compiles every library again,
+# musl and compiler-rt included.
+clean-deps: clean
+ rm -rf $(CACHE)/nimcache-host $(CACHE)/nimcache-arm64 $(CACHE)/nimcache-test $(CACHE)/test_app \
+ $(CACHE)/zig-global $(CACHE)/zig-local
+
+bump:
+ sed -i 's/serverName = "todo-nim"/serverName = "todo-nim bumped"/' src/app.nim
+
+unbump:
+ sed -i 's/serverName = "todo-nim bumped"/serverName = "todo-nim"/' src/app.nim
+
+deploy:
+ source $(REPO)/scripts/env.sh && carlos deploy --account bfj --app todo-nim \
+ --target linux-arm64 --no-prompt --message "$${MESSAGE:-Deploy todo-nim $(VERSION)}" \
+ dist/app-linux-arm64
+
+.FORCE:
diff --git a/apps/nim/README.md b/apps/nim/README.md
new file mode 100644
index 0000000..257caec
--- /dev/null
+++ b/apps/nim/README.md
@@ -0,0 +1,97 @@
+# 🤖 todo-nim
+
+TodoMVC in Nim for the CARLOS bakeoff: server-rendered, works with
+JavaScript off, and keeps each visitor's list in SQLite. It runs at
+[https://todo-nim.bfj.oncarlos.com](https://todo-nim.bfj.oncarlos.com).
+
+## The stack, and why
+
+- **Nim 2.2, compiled to C.** Nim builds a small native binary, and its C
+ backend makes the static linux-arm64 build easy: `zig cc` targeting
+ musl acts as the C compiler and linker, and the result is a 1.7 MB
+ static executable that starts in milliseconds.
+- **HTTP: the standard library's `asynchttpserver`**, on `asyncdispatch`.
+ A todo list doesn't need more than a single-threaded event loop, and the
+ standard library means no framework to pin. The popular alternatives
+ don't fit this platform: Mummy and httpbeast (Jester's server) only
+ listen on TCP, and Carloku hands the app a unix socket.
+ `asynchttpserver` doesn't take a unix socket either, but it's a small
+ gap: `unixServer` in `src/app.nim` binds the socket itself and gives it
+ to the server through `std/importutils.privateAccess`. The server is a
+ copy of the standard library's (`src/httpserver.nim`) with one fix:
+ upstream doesn't limit chunked request bodies, so one POST could
+ exhaust the instance's memory. The copy holds chunked bodies to the same
+ 1 MiB limit as `Content-Length` ones. The standard library's docs call
+ this server basic; it has no request timeouts or keep-alive limits.
+ Carloku's edge sits in front of it, and that's an acceptable trade for
+ an app this size.
+- **Routing: a `case` on the path.** Six actions and four pages don't need
+ a router.
+- **Templates: Nim's built-in `stdtmpl` source filter** (`src/page.nimf`).
+ It compiles the template into an ordinary Nim proc, so the markup is
+ type-checked and there's nothing to load at run time. Titles go through
+ `xmltree.escape`.
+- **SQLite: `db_connector`'s `sqlite3` binding** (the database module that
+ moved out of Nim's standard library) over the **SQLite 3.54.1
+ amalgamation, compiled into the binary.** The binding normally loads
+ `libsqlite3.so`; `--dynlibOverride:sqlite3` turns that off so it links
+ against the compiled-in copy. `src/store.nim` prepares every statement
+ once and binds parameters, so no user input is ever spliced into SQL.
+ WAL mode, `synchronous = NORMAL`, and numbered migrations that only add
+ (`PRAGMA user_version` records how far the file has got).
+- **JavaScript: one plain ES module, no framework** (`static/app.js`). It
+ sends the same form POSTs with an `X-Fragment: 1` header, and the server
+ answers with the re-rendered list section instead of a 303. Rendering
+ stays in one place, the server.
+- **The CSS** is `shared/base.css` and `shared/index.css`, embedded at
+ compile time with `staticRead` and served byte for byte.
+ `static/app.css` adds the no-JavaScript controls and hides them once
+ JavaScript is running.
+
+Lists are anonymous: the first visit gets a cookie holding 128 random bits
+from the OS (`std/sysrand`), and every query is scoped to it. See
+`AGENTS.md` for the tenancy declaration.
+
+## Build, run, test, deploy
+
+Put the toolchains on `PATH` first: `source scripts/env.sh` from the repo
+root. The first build fetches `db_connector` into `.cache/nimble` and the
+SQLite amalgamation into `.cache/dl`, checking it against a pinned SHA-256.
+
+```
+make build # dist/app for this machine
+make build-arm64 # dist/app-linux-arm64, static (zig cc, musl)
+make test # unit tests: the store, and the routes with fake requests
+make clean # removes dist/
+make clean-deps # also removes compiled C: .cache/nimcache-* and zig's caches
+```
+
+Run it locally and drive the shared behaviour suite against it:
+
+```
+dist/app --listen 127.0.0.1:8080 --db $TMPDIR/nim.db
+scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/nim-suite.json
+```
+
+On Carloku it runs as `app --socket <path> --db <path>`, reads
+`CARLOS_HOST` for its origin, and sets `Secure` on its cookie. It exits
+cleanly on SIGTERM.
+
+`/api/version` reports the git short SHA the binary was built from, so
+commit before building what you deploy. Then:
+
+```
+scripts/boot-check.sh dist/app-linux-arm64
+make deploy MESSAGE="what changed"
+```
+
+## Layout
+
+| Path | What |
+|---|---|
+| `src/app.nim` | Flags, the server, routes and cookies |
+| `src/httpserver.nim` | The standard library's HTTP server, with chunked bodies limited |
+| `src/store.nim` | SQLite: schema, migrations, every query |
+| `src/views.nim`, `src/page.nimf` | The page and the list fragment |
+| `static/app.js`, `static/app.css` | The JavaScript layer and the no-JS controls |
+| `tests/test_app.nim` | Unit tests |
diff --git a/apps/nim/STATUS.md b/apps/nim/STATUS.md
new file mode 100644
index 0000000..b5b8208
--- /dev/null
+++ b/apps/nim/STATUS.md
@@ -0,0 +1,90 @@
+LIVE https://todo-nim.bfj.oncarlos.com 290dbb4
+
+## Where it stands
+
+- Deployed `290dbb4` (the second review round's fix) to `todo-nim` on
+ account `bfj`, channel `edge`. `carlos deploy` confirmed it through the
+ `/api/version` build probe. Earlier deploys: `df803f7`, then `6aa1f35`.
+- Shared behaviour suite: **67 passed, 3 skipped** both locally
+ (`dist/app --listen`) and against
+ `https://todo-nim.bfj.oncarlos.com`. The skips are the suite's own: blur
+ and Escape editing with JavaScript off, and the screenshot test with
+ JavaScript off.
+- `make test`: 17 unit tests pass (store scoping, filters, toggle-all,
+ Unicode and 10,000-character titles, Unicode-whitespace trimming, migrations, cookies, 303s, 403 on
+ a foreign Origin, 404 on a foreign id, the edit view, fragments, static
+ files).
+- `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz` and
+ `/api/version` answer 200 on a unix socket under qemu, and SIGTERM
+ exits 0.
+- The binary is a 1.7 MB static aarch64 ELF (zig cc, musl). SQLite 3.54.1
+ is compiled in.
+
+## Worth knowing
+
+- `src/httpserver.nim` is a copy of Nim 2.2.12's `std/asynchttpserver`
+ with a single change, marked "todo-nim": chunked bodies are limited.
+ When upgrading Nim, re-copy it and re-apply that change.
+- `asynchttpserver` has no unix-socket mode. `unixServer` in `src/app.nim`
+ binds the socket and gives it to the server through
+ `std/importutils.privateAccess`, and it has to set `maxFDs` too:
+ without it `shouldAcceptRequest` is always false and the server
+ silently never accepts. That's the one place this app reaches into the
+ standard library's internals. A `listen(domain = AF_UNIX)` option
+ upstream would remove it.
+- The README and AGENTS.md prose didn't go through a copy review. This
+ session ran unattended, and that review needs a person.
+
+## Review round 2
+
+The one finding is fixed in `290dbb4`:
+
+1. **Invalid UTF-8 in titles (security).** `title=%80` made
+ `unicode.strip` index out of bounds. The `IndexDefect` isn't a
+ `CatchableError`, so it got past the handler and stopped the server.
+ Now `action` runs `unicode.validateUtf8` on the decoded title before
+ anything else touches it, and answers **400** if it isn't valid. That
+ check is bounds-safe. Browsers always send UTF-8, so a real visitor
+ never hits this. A unit test covers truncated 2-, 3- and 4-byte
+ sequences and stray bytes for both add and edit, and the list is
+ unchanged afterwards. Before the fix, `title=%80` killed the local
+ binary. After it, the binary and the live host both answer 400 and
+ keep serving.
+
+After the fix the shared suite gave 67 passed and 3 skipped both locally
+and against the live host, and the boot check passed.
+
+## Review round 1
+
+All four findings are fixed in `6aa1f35`:
+
+1. **Chunked bodies (security).** The parser lives inside the standard
+ library, so the server is now vendored as `src/httpserver.nim`. Each
+ chunk's declared size is checked against what's left of the body limit
+ before `recv` allocates anything. Oversized, overflowing and
+ many-small-chunk bodies all get a 413 and the connection closes. The
+ limit is 1 MiB for both chunked and `Content-Length` bodies (it was
+ 8 MiB); a 10,000-character title needs at most about 120 KB. Checked
+ with raw-socket requests against the binary: it stayed at 7 MB RSS.
+2. **Unicode trim.** Add and edit now strip the whitespace JavaScript's
+ `String.prototype.trim` removes (U+00A0, U+2000–U+200A, U+3000, U+FEFF
+ and the rest). A title of only non-breaking spaces adds nothing, and
+ editing to that deletes the item. There's a unit test for both.
+3. **Edits across re-renders.** `render()` keeps the item being edited
+ (text, cursor, focus) and restores it on the same item in the new
+ list, and the blur that removing the old field may fire doesn't
+ count as a save. I checked it with Playwright: toggle one item with
+ the response held for 800 ms, double-click another, type, and wait.
+ The edit is still open, focused and holding the text, and Enter saves
+ it.
+4. **Zig's caches.** The Makefile exports `ZIG_GLOBAL_CACHE_DIR` and
+ `ZIG_LOCAL_CACHE_DIR` under `.cache/`, and `clean-deps` removes them.
+ After `clean-deps`, `make build-arm64` rebuilt musl and compiler-rt
+ (61 MB of cache) from scratch.
+
+After the fixes the shared suite gave 67 passed and 3 skipped both
+locally and against the live host, and the boot check passed.
+
+## Review findings not fixed
+
+None.
diff --git a/apps/nim/src/app.nim b/apps/nim/src/app.nim
new file mode 100644
index 0000000..af83ff3
--- /dev/null
+++ b/apps/nim/src/app.nim
@@ -0,0 +1,256 @@
+## TodoMVC on CARLOS: the HTTP server and its routes.
+##
+## app --socket <path> --db <path> (on Carloku)
+## app --listen 127.0.0.1:<port> --db <path> (local runs and tests)
+
+import std/[asyncdispatch, asyncnet, cookies, importutils, options,
+ nativesockets, net, os, parseopt, posix, strutils, strtabs, unicode, uri]
+import httpserver, store, views
+
+const
+ appVersion {.strdefine.} = "dev"
+ # `make bump` edits this string so the next build has to recompile this file.
+ serverName = "todo-nim"
+ cookieName = "todo_list"
+ baseCss = staticRead("../../../shared/base.css")
+ indexCss = staticRead("../../../shared/index.css")
+ appCss = staticRead("../static/app.css")
+ appJs = staticRead("../static/app.js")
+ # A 10,000-character title of 4-byte characters, percent-encoded, is
+ # about 120 KB. Bodies past this are refused, chunked or not.
+ maxBody = 1024 * 1024
+
+type
+ Config = object
+ socket, listen, db: string
+
+ App = ref object
+ store: Store
+ origin: string ## the only Origin a POST may carry
+ secure: bool ## served over https, so the cookie gets `Secure`
+
+ Reply = object
+ code: HttpCode
+ body: string
+ headers: seq[(string, string)]
+
+# Requests
+
+proc header(req: Request, name: string): string =
+ if req.headers.hasKey(name): $req.headers[name] else: ""
+
+proc parseFilter(s: string): Filter =
+ case s
+ of "active": fActive
+ of "completed": fCompleted
+ else: fAll
+
+const titleSpaces = block:
+ # What JavaScript's String.prototype.trim removes, as TodoMVC does.
+ var r: seq[Rune]
+ for c in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, 0x2028, 0x2029,
+ 0x202F, 0x205F, 0x3000, 0xFEFF]:
+ r.add Rune(c)
+ for c in 0x2000..0x200A: r.add Rune(c)
+ r
+
+proc trimTitle(s: string): string =
+ ## Call only on valid UTF-8: `unicode.strip` indexes out of bounds on a
+ ## truncated sequence, and that defect would stop the whole server.
+ unicode.strip(s, runes = titleSpaces)
+
+proc formFields(req: Request): StringTableRef =
+ result = newStringTable(modeCaseSensitive)
+ for (k, v) in decodeQuery(req.body):
+ result[k] = v
+
+proc queryParam(req: Request, name: string): string =
+ for (k, v) in decodeQuery(req.url.query):
+ if k == name: return v
+
+# Responses
+
+proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply =
+ Reply(code: code, body: body, headers: @{"Content-Type": contentType})
+
+proc html(body: string): Reply =
+ result = reply(Http200, body, "text/html; charset=utf-8")
+ result.headers.add ("Cache-Control", "no-store")
+
+proc asset(body, contentType: string): Reply =
+ result = reply(Http200, body, contentType)
+ result.headers.add ("Cache-Control", "public, max-age=86400")
+
+proc seeOther(path: string): Reply =
+ result = reply(Http303)
+ result.headers.add ("Location", path)
+
+# The visitor's list
+
+proc listFor(app: App, req: Request, r: var Reply): string =
+ ## The list the visitor's cookie names, or a new one with a cookie that
+ ## says so. Garbled and unknown cookies get a new list too.
+ let jar = parseCookies(req.header("Cookie"))
+ result = jar.getOrDefault(cookieName)
+ if not app.store.listExists(result):
+ result = app.store.createList()
+ let cookie = setCookie(cookieName, result, path = "/", noName = true,
+ secure = app.secure, httpOnly = true, maxAge = some(400 * 24 * 3600),
+ sameSite = Lax)
+ r.headers.add ("Set-Cookie", cookie)
+
+proc view(app: App, list: string, filter: Filter, editing = 0'i64): View =
+ let (total, completed) = app.store.counts(list)
+ View(todos: app.store.todos(list, filter), total: total, completed: completed,
+ filter: filter, editing: editing, version: appVersion)
+
+# Routes
+
+proc page(app: App, req: Request, filter: Filter): Reply =
+ var r: Reply
+ let list = app.listFor(req, r)
+ let editing = try: parseBiggestInt(req.queryParam("edit")) except ValueError: 0
+ result = html(renderPage(app.view(list, filter, editing)))
+ result.headers.add r.headers
+
+proc action(app: App, req: Request, path: string): Reply =
+ ## A form POST: change the list, then 303 back to the filter, or render the
+ ## changed list as a fragment when the page's JavaScript asks for one.
+ let origin = req.header("Origin")
+ if origin != "" and origin != app.origin:
+ return reply(Http403, "Cross-origin request refused\n")
+ var r: Reply
+ let list = app.listFor(req, r)
+ let form = req.formFields()
+ let filter = parseFilter(form.getOrDefault("filter"))
+ # Browsers always send UTF-8; anything else is refused before it's used.
+ if validateUtf8(form.getOrDefault("title")) != -1:
+ return reply(Http400, "The title isn't valid UTF-8\n")
+ let parts = path.split('/') # "", "todos", <id or verb>, [verb]
+ var found = true
+ if parts.len == 2:
+ let title = trimTitle(form.getOrDefault("title"))
+ if title.len > 0: app.store.add(list, title)
+ elif parts.len == 3 and parts[2] == "toggle-all":
+ app.store.toggleAll(list)
+ elif parts.len == 3 and parts[2] == "clear-completed":
+ app.store.clearCompleted(list)
+ elif parts.len == 4:
+ let id = try: parseBiggestInt(parts[2]) except ValueError: -1
+ case parts[3]
+ of "toggle": found = app.store.toggle(list, id)
+ of "destroy": found = app.store.destroy(list, id)
+ of "edit":
+ let title = trimTitle(form.getOrDefault("title"))
+ found = if title.len > 0: app.store.rename(list, id, title)
+ else: app.store.destroy(list, id)
+ else: return reply(Http404, "Not found\n")
+ else:
+ return reply(Http404, "Not found\n")
+ result =
+ if not found: reply(Http404, "No such item in your list\n")
+ elif req.header("X-Fragment") == "1":
+ html(renderMain(app.view(list, filter)))
+ else: seeOther(filterPath(filter))
+ result.headers.add r.headers
+
+proc route(app: App, req: Request): Reply =
+ let path = req.url.path
+ case req.reqMethod
+ of HttpGet:
+ case path
+ of "/": app.page(req, fAll)
+ of "/active": app.page(req, fActive)
+ of "/completed": app.page(req, fCompleted)
+ of "/healthz": reply(Http200, "ok\n")
+ of "/api/version": reply(Http200, appVersion)
+ of "/static/base.css": asset(baseCss, "text/css; charset=utf-8")
+ of "/static/index.css": asset(indexCss, "text/css; charset=utf-8")
+ of "/static/app.css": asset(appCss, "text/css; charset=utf-8")
+ of "/static/app.js": asset(appJs, "text/javascript; charset=utf-8")
+ else: reply(Http404, "Not found\n")
+ of HttpPost:
+ if path == "/todos" or path.startsWith("/todos/"): app.action(req, path)
+ else: reply(Http404, "Not found\n")
+ else:
+ reply(Http405, "Method not allowed\n")
+
+proc handle(app: App, req: Request) {.async.} =
+ var r: Reply
+ try:
+ r = app.route(req)
+ except CatchableError as e:
+ stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg
+ r = reply(Http500, "Internal error\n")
+ r.headers.add ("Server", serverName)
+ # The server sets Content-Length from the body, in bytes.
+ await req.respond(r.code, r.body, newHttpHeaders(r.headers))
+
+# Startup and shutdown
+
+const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
+
+proc parseArgs(): Config =
+ # Every flag takes a value; listing one with none makes `--flag value` work.
+ var p = initOptParser(commandLineParams(), longNoVal = @["help"])
+ for kind, key, val in p.getopt():
+ if kind != cmdLongOption:
+ quit "unexpected argument: " & key, 2
+ case key
+ of "socket": result.socket = val
+ of "listen": result.listen = val
+ of "db": result.db = val
+ of "help": quit usage, 0
+ else: quit "unknown flag --" & key, 2
+ if result.db == "" or (result.socket == "") == (result.listen == ""):
+ quit usage, 2
+
+proc unixServer(path: string): AsyncHttpServer =
+ ## The server only listens on TCP; hand it a unix socket instead.
+ privateAccess(AsyncHttpServer)
+ result = newAsyncHttpServer(maxBody = maxBody)
+ if fileExists(path) or symlinkExists(path): removeFile(path)
+ let sock = newAsyncSocket(Domain.AF_UNIX, SockType.SOCK_STREAM, Protocol.IPPROTO_IP)
+ sock.bindUnix(path)
+ sock.listen()
+ result.socket = sock
+ result.maxFDs = try: maxDescriptors() except OSError: nimMaxDescriptorsFallback
+
+proc main() =
+ let cfg = parseArgs()
+ let app = App(store: store.open(cfg.db))
+ var server: AsyncHttpServer
+ if cfg.socket != "":
+ server = unixServer(cfg.socket)
+ app.origin = "https://" & getEnv("CARLOS_HOST")
+ app.secure = true
+ else:
+ let i = cfg.listen.rfind(':')
+ let host = if i < 0: "" else: cfg.listen[0 ..< i]
+ let port = try: parseInt(cfg.listen[i + 1 .. ^1]) except ValueError: -1
+ if host != "127.0.0.1" or port notin 1..65535:
+ quit "--listen must be 127.0.0.1:<port>", 2
+ server = newAsyncHttpServer(maxBody = maxBody)
+ server.listen(Port(port), host)
+ app.origin = "http://" & cfg.listen
+
+ proc stop(fd: AsyncFD): bool =
+ server.close()
+ if cfg.socket != "": discard tryRemoveFile(cfg.socket)
+ app.store.close()
+ quit 0
+ addSignal(SIGTERM.int, stop)
+ addSignal(SIGINT.int, stop)
+
+ stderr.writeLine "todo-nim ", appVersion, " listening on ",
+ (if cfg.socket != "": cfg.socket else: cfg.listen)
+ proc serve() {.async.} =
+ while true:
+ if server.shouldAcceptRequest():
+ await server.acceptRequest(proc (req: Request): Future[void] = app.handle(req))
+ else:
+ await sleepAsync(10)
+ waitFor serve()
+
+when isMainModule and not defined(testing):
+ main()
diff --git a/apps/nim/src/httpserver.nim b/apps/nim/src/httpserver.nim
new file mode 100644
index 0000000..6ae5f75
--- /dev/null
+++ b/apps/nim/src/httpserver.nim
@@ -0,0 +1,454 @@
+#
+#
+# Nim's Runtime Library
+# (c) Copyright 2015 Dominik Picheta
+#
+# See the file "copying.txt", included in this
+# distribution, for details about the copyright.
+#
+
+## todo-nim's copy of Nim 2.2.12's `std/asynchttpserver`, changed in one
+## place: a chunked request body is held to `maxBody` like a
+## `Content-Length` one. Upstream passes each chunk's declared size straight
+## to `recv`, which allocates that much before any data arrives, and never
+## limits the total, so one chunked POST could exhaust the instance's
+## memory. Look for "todo-nim" below.
+##
+## This module implements a high performance asynchronous HTTP server.
+##
+## This HTTP server has not been designed to be used in production, but
+## for testing applications locally. Because of this, when deploying your
+## application in production you should use a reverse proxy (for example nginx)
+## instead of allowing users to connect directly to this server.
+
+runnableExamples("-r:off"):
+ # This example will create an HTTP server on an automatically chosen port.
+ # It will respond to all requests with a `200 OK` response code and "Hello World"
+ # as the response body.
+ import std/asyncdispatch
+ proc main {.async.} =
+ var server = newAsyncHttpServer()
+ proc cb(req: Request) {.async.} =
+ echo (req.reqMethod, req.url, req.headers)
+ let headers = {"Content-type": "text/plain; charset=utf-8"}
+ await req.respond(Http200, "Hello World", headers.newHttpHeaders())
+
+ server.listen(Port(0)) # or Port(8080) to hardcode the standard HTTP port.
+ let port = server.getPort
+ echo "test this with: curl localhost:" & $port.uint16 & "/"
+ while true:
+ if server.shouldAcceptRequest():
+ await server.acceptRequest(cb)
+ else:
+ # too many concurrent connections, `maxFDs` exceeded
+ # wait 500ms for FDs to be closed
+ await sleepAsync(500)
+
+ waitFor main()
+
+import std/[asyncnet, asyncdispatch, parseutils, uri, strutils]
+import std/httpcore
+from std/nativesockets import getLocalAddr, Domain, AF_INET, AF_INET6
+import std/private/since
+
+when defined(nimPreviewSlimSystem):
+ import std/assertions
+
+export httpcore except parseHeader
+
+const
+ maxLine = 8*1024
+
+# TODO: If it turns out that the decisions that asynchttpserver makes
+# explicitly, about whether to close the client sockets or upgrade them are
+# wrong, then add a return value which determines what to do for the callback.
+# Also, maybe move `client` out of `Request` object and into the args for
+# the proc.
+type
+ Request* = object
+ client*: AsyncSocket # TODO: Separate this into a Response object?
+ reqMethod*: HttpMethod
+ headers*: HttpHeaders
+ protocol*: tuple[orig: string, major, minor: int]
+ url*: Uri
+ hostname*: string ## The hostname of the client that made the request.
+ body*: string
+
+ AsyncHttpServer* = ref object
+ socket: AsyncSocket
+ reuseAddr: bool
+ reusePort: bool
+ maxBody: int ## The maximum content-length that will be read for the body.
+ maxFDs: int
+
+proc getPort*(self: AsyncHttpServer): Port {.since: (1, 5, 1).} =
+ ## Returns the port `self` was bound to.
+ ##
+ ## Useful for identifying what port `self` is bound to, if it
+ ## was chosen automatically, for example via `listen(Port(0))`.
+ runnableExamples:
+ from std/nativesockets import Port
+ let server = newAsyncHttpServer()
+ server.listen(Port(0))
+ assert server.getPort.uint16 > 0
+ server.close()
+ result = getLocalAddr(self.socket)[1]
+
+proc newAsyncHttpServer*(reuseAddr = true, reusePort = false,
+ maxBody = 8388608): AsyncHttpServer =
+ ## Creates a new `AsyncHttpServer` instance.
+ result = AsyncHttpServer(reuseAddr: reuseAddr, reusePort: reusePort, maxBody: maxBody)
+
+proc addHeaders(msg: var string, headers: HttpHeaders) =
+ for k, v in headers:
+ msg.add(k & ": " & v & "\c\L")
+
+proc sendHeaders*(req: Request, headers: HttpHeaders): Future[void] =
+ ## Sends the specified headers to the requesting client.
+ var msg = ""
+ addHeaders(msg, headers)
+ return req.client.send(msg)
+
+proc respond*(req: Request, code: HttpCode, content: string,
+ headers: HttpHeaders = nil): Future[void] =
+ ## Responds to the request with the specified `HttpCode`, headers and
+ ## content.
+ ##
+ ## This procedure will **not** close the client socket.
+ ##
+ ## Example:
+ ## ```Nim
+ ## import std/json
+ ## proc handler(req: Request) {.async.} =
+ ## if req.url.path == "/hello-world":
+ ## let msg = %* {"message": "Hello World"}
+ ## let headers = newHttpHeaders([("Content-Type","application/json")])
+ ## await req.respond(Http200, $msg, headers)
+ ## else:
+ ## await req.respond(Http404, "Not Found")
+ ## ```
+ var msg = "HTTP/1.1 " & $code & "\c\L"
+
+ if headers != nil:
+ msg.addHeaders(headers)
+
+ # If the headers did not contain a Content-Length use our own
+ if headers.isNil() or not headers.hasKey("Content-Length"):
+ msg.add("Content-Length: ")
+ # this particular way saves allocations:
+ msg.addInt content.len
+ msg.add "\c\L"
+
+ msg.add "\c\L"
+ msg.add(content)
+ result = req.client.send(msg)
+
+proc respondError(req: Request, code: HttpCode): Future[void] =
+ ## Responds to the request with the specified `HttpCode`.
+ let content = $code
+ var msg = "HTTP/1.1 " & content & "\c\L"
+
+ msg.add("Content-Length: " & $content.len & "\c\L\c\L")
+ msg.add(content)
+ result = req.client.send(msg)
+
+proc parseProtocol(protocol: string): tuple[orig: string, major, minor: int] =
+ result = default(tuple[orig: string, major, minor: int])
+ var i = protocol.skipIgnoreCase("HTTP/")
+ if i != 5:
+ raise newException(ValueError, "Invalid request protocol. Got: " &
+ protocol)
+ result.orig = protocol
+ i.inc protocol.parseSaturatedNatural(result.major, i)
+ if i < protocol.len: inc i # Skip .
+ i.inc protocol.parseSaturatedNatural(result.minor, i)
+
+proc sendStatus(client: AsyncSocket, status: string): Future[void] =
+ client.send("HTTP/1.1 " & status & "\c\L\c\L")
+
+func hasChunkedEncoding(request: Request): bool =
+ ## Searches for a chunked transfer encoding
+ const transferEncoding = "Transfer-Encoding"
+
+ if request.headers.hasKey(transferEncoding):
+ for encoding in seq[string](request.headers[transferEncoding]):
+ if "chunked" == encoding.strip:
+ # Returns true if it is both an HttpPost and has chunked encoding
+ return request.reqMethod == HttpPost
+ return false
+
+proc processRequest(
+ server: AsyncHttpServer,
+ req: FutureVar[Request],
+ client: AsyncSocket,
+ address: sink string,
+ lineFut: FutureVar[string],
+ callback: proc (request: Request): Future[void] {.closure, gcsafe.},
+): Future[bool] {.async.} =
+
+ # Alias `request` to `req.mget()` so we don't have to write `mget` everywhere.
+ template request(): Request =
+ req.mget()
+
+ # GET /path HTTP/1.1
+ # Header: val
+ # \n
+ request.headers.clear()
+ request.body = ""
+ when defined(gcArc) or defined(gcOrc) or defined(gcAtomicArc):
+ request.hostname = address
+ else:
+ request.hostname.shallowCopy(address)
+ assert client != nil
+ request.client = client
+
+ # We should skip at least one empty line before the request
+ # https://tools.ietf.org/html/rfc7230#section-3.5
+ for i in 0..1:
+ lineFut.mget().setLen(0)
+ lineFut.clean()
+ await client.recvLineInto(lineFut, maxLength = maxLine) # TODO: Timeouts.
+
+ if lineFut.mget == "":
+ client.close()
+ return false
+
+ if lineFut.mget.len > maxLine:
+ await request.respondError(Http413)
+ client.close()
+ return false
+ if lineFut.mget != "\c\L":
+ break
+
+ # First line - GET /path HTTP/1.1
+ var i = 0
+ for linePart in lineFut.mget.split(' '):
+ case i
+ of 0:
+ case linePart
+ of "GET": request.reqMethod = HttpGet
+ of "POST": request.reqMethod = HttpPost
+ of "HEAD": request.reqMethod = HttpHead
+ of "PUT": request.reqMethod = HttpPut
+ of "DELETE": request.reqMethod = HttpDelete
+ of "PATCH": request.reqMethod = HttpPatch
+ of "OPTIONS": request.reqMethod = HttpOptions
+ of "CONNECT": request.reqMethod = HttpConnect
+ of "TRACE": request.reqMethod = HttpTrace
+ else:
+ asyncCheck request.respondError(Http400)
+ return true # Retry processing of request
+ of 1:
+ try:
+ parseUri(linePart, request.url)
+ except ValueError:
+ asyncCheck request.respondError(Http400)
+ return true
+ of 2:
+ try:
+ request.protocol = parseProtocol(linePart)
+ except ValueError:
+ asyncCheck request.respondError(Http400)
+ return true
+ else:
+ await request.respondError(Http400)
+ return true
+ inc i
+
+ # Headers
+ while true:
+ i = 0
+ lineFut.mget.setLen(0)
+ lineFut.clean()
+ await client.recvLineInto(lineFut, maxLength = maxLine)
+
+ if lineFut.mget == "":
+ client.close(); return false
+ if lineFut.mget.len > maxLine:
+ await request.respondError(Http413)
+ client.close(); return false
+ if lineFut.mget == "\c\L": break
+ let (key, value) = parseHeader(lineFut.mget)
+ request.headers[key] = value
+ # Ensure the client isn't trying to DoS us.
+ if request.headers.len > headerLimit:
+ await client.sendStatus("400 Bad Request")
+ request.client.close()
+ return false
+
+ if request.reqMethod == HttpPost:
+ # Check for Expect header
+ if request.headers.hasKey("Expect"):
+ if "100-continue" in request.headers["Expect"]:
+ await client.sendStatus("100 Continue")
+ else:
+ await client.sendStatus("417 Expectation Failed")
+
+ # Read the body
+ # - Check for Content-length header
+ if request.headers.hasKey("Content-Length"):
+ var contentLength = 0
+ if parseSaturatedNatural(request.headers["Content-Length"], contentLength) == 0:
+ await request.respond(Http400, "Bad Request. Invalid Content-Length.")
+ return true
+ else:
+ if contentLength > server.maxBody:
+ await request.respondError(Http413)
+ return false
+ request.body = await client.recv(contentLength)
+ if request.body.len != contentLength:
+ await request.respond(Http400, "Bad Request. Content-Length does not match actual.")
+ return true
+ elif hasChunkedEncoding(request):
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding
+ var sizeOrData = 0
+ var bytesToRead = 0
+ request.body = ""
+
+ while true:
+ lineFut.mget.setLen(0)
+ lineFut.clean()
+
+ # The encoding format alternates between specifying a number of bytes to read
+ # and the data to be read, of the previously specified size
+ if sizeOrData mod 2 == 0:
+ # Expect a number of chars to read
+ await client.recvLineInto(lineFut, maxLength = maxLine)
+ try:
+ bytesToRead = lineFut.mget.parseHexInt
+ except ValueError:
+ # Malformed request
+ await request.respond(Http411, ("Invalid chunked transfer encoding - " &
+ "chunk data size must be hex encoded"))
+ return true
+ # todo-nim: check the size before reading (and so allocating) the
+ # chunk, against what the body may still grow by. A negative size
+ # means the hex overflowed.
+ if bytesToRead < 0 or bytesToRead > server.maxBody - request.body.len:
+ await request.respondError(Http413)
+ return false
+ else:
+ if bytesToRead == 0:
+ # Done reading chunked data
+ break
+
+ # Read bytesToRead and add to body
+ let chunk = await client.recv(bytesToRead)
+ request.body.add(chunk)
+ # Skip \r\n (chunk terminating bytes per spec)
+ let separator = await client.recv(2)
+ if separator != "\r\n":
+ await request.respond(Http400, "Bad Request. Encoding separator must be \\r\\n")
+ return true
+
+ inc sizeOrData
+ elif request.reqMethod == HttpPost:
+ await request.respond(Http411, "Content-Length required.")
+ return true
+
+ # Call the user's callback.
+ await callback(request)
+
+ if "upgrade" in request.headers.getOrDefault("connection"):
+ return false
+
+ # The request has been served, from this point on returning `true` means the
+ # connection will not be closed and will be kept in the connection pool.
+
+ # Persistent connections
+ if (request.protocol == HttpVer11 and
+ cmpIgnoreCase(request.headers.getOrDefault("connection"), "close") != 0) or
+ (request.protocol == HttpVer10 and
+ cmpIgnoreCase(request.headers.getOrDefault("connection"), "keep-alive") == 0):
+ # In HTTP 1.1 we assume that connection is persistent. Unless connection
+ # header states otherwise.
+ # In HTTP 1.0 we assume that the connection should not be persistent.
+ # Unless the connection header states otherwise.
+ return true
+ else:
+ request.client.close()
+ return false
+
+proc processClient(server: AsyncHttpServer, client: AsyncSocket, address: string,
+ callback: proc (request: Request):
+ Future[void] {.closure, gcsafe.}) {.async.} =
+ var request = newFutureVar[Request]("asynchttpserver.processClient")
+ request.mget().url = initUri()
+ request.mget().headers = newHttpHeaders()
+ var lineFut = newFutureVar[string]("asynchttpserver.processClient")
+ lineFut.mget() = newStringOfCap(80)
+
+ while not client.isClosed:
+ let retry = await processRequest(
+ server, request, client, address, lineFut, callback
+ )
+ if not retry:
+ client.close()
+ break
+
+const
+ nimMaxDescriptorsFallback* {.intdefine.} = 16_000 ## fallback value for \
+ ## when `maxDescriptors` is not available.
+ ## This can be set on the command line during compilation
+ ## via `-d:nimMaxDescriptorsFallback=N`
+
+proc listen*(server: AsyncHttpServer; port: Port; address = ""; domain = AF_INET) =
+ ## Listen to the given port and address.
+ when declared(maxDescriptors):
+ server.maxFDs = try: maxDescriptors() except: nimMaxDescriptorsFallback
+ else:
+ server.maxFDs = nimMaxDescriptorsFallback
+ server.socket = newAsyncSocket(domain)
+ if server.reuseAddr:
+ server.socket.setSockOpt(OptReuseAddr, true)
+ when not defined(nuttx):
+ if server.reusePort:
+ server.socket.setSockOpt(OptReusePort, true)
+ server.socket.bindAddr(port, address)
+ server.socket.listen()
+
+proc shouldAcceptRequest*(server: AsyncHttpServer;
+ assumedDescriptorsPerRequest = 5): bool {.inline.} =
+ ## Returns true if the process's current number of opened file
+ ## descriptors is still within the maximum limit and so it's reasonable to
+ ## accept yet another request.
+ result = assumedDescriptorsPerRequest < 0 or
+ (activeDescriptors() + assumedDescriptorsPerRequest < server.maxFDs)
+
+proc acceptRequest*(server: AsyncHttpServer,
+ callback: proc (request: Request): Future[void] {.closure, gcsafe.}) {.async.} =
+ ## Accepts a single request. Write an explicit loop around this proc so that
+ ## errors can be handled properly.
+ var (address, client) = await server.socket.acceptAddr()
+ asyncCheck processClient(server, client, address, callback)
+
+proc serve*(server: AsyncHttpServer, port: Port,
+ callback: proc (request: Request): Future[void] {.closure, gcsafe.},
+ address = "";
+ assumedDescriptorsPerRequest = -1;
+ domain = AF_INET) {.async.} =
+ ## Starts the process of listening for incoming HTTP connections on the
+ ## specified address and port.
+ ##
+ ## When a request is made by a client the specified callback will be called.
+ ##
+ ## If `assumedDescriptorsPerRequest` is 0 or greater the server cares about
+ ## the process's maximum file descriptor limit. It then ensures that the
+ ## process still has the resources for `assumedDescriptorsPerRequest`
+ ## file descriptors before accepting a connection.
+ ##
+ ## You should prefer to call `acceptRequest` instead with a custom server
+ ## loop so that you're in control over the error handling and logging.
+ listen server, port, address, domain
+ while true:
+ if shouldAcceptRequest(server, assumedDescriptorsPerRequest):
+ var (address, client) = await server.socket.acceptAddr()
+ asyncCheck processClient(server, client, address, callback)
+ else:
+ poll()
+ #echo(f.isNil)
+ #echo(f.repr)
+
+proc close*(server: AsyncHttpServer) =
+ ## Terminates the async http server instance.
+ server.socket.close()
diff --git a/apps/nim/src/page.nimf b/apps/nim/src/page.nimf
new file mode 100644
index 0000000..4ed3f5a
--- /dev/null
+++ b/apps/nim/src/page.nimf
@@ -0,0 +1,75 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#proc renderMain*(v: View): string =
+# result = ""
+# let f = $v.filter
+# if v.total > 0:
+ <section class="main">
+ <input id="toggle-all" class="toggle-all" type="checkbox"${checkedIf(v.total == v.completed)}>
+ <label for="toggle-all">Mark all as complete</label>
+ <form class="inline" method="post" action="/todos/toggle-all"><input type="hidden" name="filter" value="$f"><button class="toggle-all-button" data-action="toggle-all">Mark all as complete</button></form>
+ <ul class="todo-list">
+# for t in v.todos:
+# let editing = t.id == v.editing
+# let title = h(t.title)
+ <li${classes(t.completed, editing)} data-id="${t.id}">
+ <div class="view">
+ <input class="toggle" type="checkbox"${checkedIf(t.completed)}>
+ <label>$title</label>
+ <form class="inline" method="post" action="/todos/${t.id}/toggle"><input type="hidden" name="filter" value="$f"><button class="toggle-button" data-action="toggle">${if t.completed: "Mark as active" else: "Mark as complete"}</button></form>
+ <a class="edit-link" data-action="edit" href="${filterPath(v.filter)}?edit=${t.id}">edit</a>
+ <form class="inline" method="post" action="/todos/${t.id}/destroy"><input type="hidden" name="filter" value="$f"><button class="destroy" aria-label="Delete"></button></form>
+ </div>
+# if editing:
+ <form class="inline" method="post" action="/todos/${t.id}/edit"><input type="hidden" name="filter" value="$f"><input class="edit" name="title" value="$title" autofocus></form>
+# else:
+ <input class="edit" value="$title">
+# end if
+ </li>
+# end for
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>${v.total - v.completed}</strong> ${if v.total - v.completed == 1: "item" else: "items"} left</span>
+ <ul class="filters">
+# for (filter, name) in [(fAll, "All"), (fActive, "Active"), (fCompleted, "Completed")]:
+ <li>
+ <a${if filter == v.filter: " class=\"selected\"" else: ""} href="${filterPath(filter)}">$name</a>
+ </li>
+# end for
+ </ul>
+# if v.completed > 0:
+ <form class="inline" method="post" action="/todos/clear-completed"><input type="hidden" name="filter" value="$f"><button class="clear-completed">Clear completed</button></form>
+# end if
+ </footer>
+# end if
+#end proc
+#
+#proc renderPage*(v: View): string =
+# result = ""
+<!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.classList.add("js")</script>
+ <link rel="stylesheet" href="/static/base.css">
+ <link rel="stylesheet" href="/static/index.css">
+ <link rel="stylesheet" href="/static/app.css?v=${h(v.version)}">
+ <script type="module" src="/static/app.js?v=${h(v.version)}"></script>
+ </head>
+ <body>
+ <section class="todoapp" data-filter="${v.filter}">
+ <header class="header">
+ <h1>todos</h1>
+ <form class="inline" method="post" action="/todos"><input type="hidden" name="filter" value="${v.filter}"><input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"${if v.editing == 0: " autofocus" else: ""}></form>
+ </header>
+${renderMain(v)} </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> · Nim</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
+#end proc
diff --git a/apps/nim/src/store.nim b/apps/nim/src/store.nim
new file mode 100644
index 0000000..e64d3ea
--- /dev/null
+++ b/apps/nim/src/store.nim
@@ -0,0 +1,208 @@
+## SQLite storage: one file, WAL mode, every query scoped to one list.
+##
+## The binding is `db_connector/sqlite3`; the SQLite amalgamation is compiled
+## into the binary here, and the build passes `--dynlibOverride:sqlite3` so
+## the binding links against it instead of loading `libsqlite3.so`.
+
+import std/[sysrand, strutils, times]
+import db_connector/sqlite3
+
+const
+ sqliteSource {.strdefine.} = "../.cache/sqlite/sqlite3.c"
+ sqliteFlags = "-O2 -DSQLITE_THREADSAFE=0 -DSQLITE_DQS=0 " &
+ "-DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 " &
+ "-DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_OMIT_DEPRECATED " &
+ "-DSQLITE_OMIT_SHARED_CACHE -DSQLITE_LIKE_DOESNT_MATCH_BLOBS"
+
+{.compile(sqliteSource, sqliteFlags).}
+
+type
+ StoreError* = object of CatchableError
+
+ Todo* = object
+ id*: int64
+ title*: string
+ completed*: bool
+
+ Store* = ref object
+ db: PSqlite3
+ stmts: seq[PStmt]
+
+ Stmt = enum
+ sListExists, sCreateList, sTodos, sAdd, sToggle, sRename, sDestroy,
+ sCountAll, sCountCompleted, sSetAll, sClearCompleted
+
+const
+ # Migrations only ever add. Append; never edit or remove a shipped entry.
+ migrations = [
+ """
+ CREATE TABLE lists (
+ id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ CREATE TABLE todos (
+ id INTEGER PRIMARY KEY,
+ list_id TEXT NOT NULL REFERENCES lists(id),
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX todos_by_list ON todos(list_id, id);
+ """,
+ ]
+
+ sql: array[Stmt, string] = [
+ sListExists: "SELECT 1 FROM lists WHERE id = ?1",
+ sCreateList: "INSERT INTO lists (id, created_at) VALUES (?1, ?2)",
+ sTodos: "SELECT id, title, completed FROM todos WHERE list_id = ?1 " &
+ "AND (?2 = 0 OR completed = ?3) ORDER BY id",
+ sAdd: "INSERT INTO todos (list_id, title, created_at) VALUES (?1, ?2, ?3)",
+ sToggle: "UPDATE todos SET completed = 1 - completed " &
+ "WHERE list_id = ?1 AND id = ?2",
+ sRename: "UPDATE todos SET title = ?3 WHERE list_id = ?1 AND id = ?2",
+ sDestroy: "DELETE FROM todos WHERE list_id = ?1 AND id = ?2",
+ sCountAll: "SELECT count(*) FROM todos WHERE list_id = ?1",
+ sCountCompleted: "SELECT count(*) FROM todos WHERE list_id = ?1 AND completed = 1",
+ sSetAll: "UPDATE todos SET completed = ?2 WHERE list_id = ?1",
+ sClearCompleted: "DELETE FROM todos WHERE list_id = ?1 AND completed = 1",
+ ]
+
+proc check(s: Store, rc: int32): int32 {.discardable.} =
+ if rc notin [SQLITE_OK, SQLITE_ROW, SQLITE_DONE]:
+ raise newException(StoreError, $errmsg(s.db))
+ rc
+
+proc execScript(s: Store, script: string) =
+ var err: cstring
+ if exec(s.db, script.cstring, nil, nil, err) != SQLITE_OK:
+ let msg = $err
+ free(err)
+ raise newException(StoreError, msg)
+
+proc scalar(s: Store, query: string): int64 =
+ var st: PStmt
+ s.check prepare_v2(s.db, query.cstring, -1, st, nil)
+ try:
+ if step(st) == SQLITE_ROW: result = column_int64(st, 0)
+ finally:
+ discard finalize(st)
+
+proc schemaVersion*(s: Store): int = s.scalar("PRAGMA user_version").int
+
+proc migrate(s: Store) =
+ for i in s.schemaVersion ..< migrations.len:
+ s.execScript("BEGIN;\n" & migrations[i] & "\nPRAGMA user_version = " &
+ $(i + 1) & ";\nCOMMIT;")
+
+proc open*(path: string): Store =
+ result = Store()
+ if sqlite3.open(path.cstring, result.db) != SQLITE_OK:
+ raise newException(StoreError, "cannot open " & path)
+ discard busy_timeout(result.db, 5000)
+ result.execScript("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; " &
+ "PRAGMA foreign_keys = ON;")
+ result.migrate()
+ result.stmts = newSeq[PStmt](Stmt.high.ord + 1)
+ for k in Stmt:
+ result.check prepare_v2(result.db, sql[k].cstring, -1, result.stmts[k.ord], nil)
+
+proc close*(s: Store) =
+ for st in s.stmts: discard finalize(st)
+ s.stmts.setLen(0)
+ discard sqlite3.close(s.db)
+
+# Statements are prepared once and reused: bind, step, then reset.
+
+type Arg = object
+ case isText: bool
+ of true: text: string
+ of false: num: int64
+
+proc toArg(v: string): Arg = Arg(isText: true, text: v)
+proc toArg(v: int64): Arg = Arg(isText: false, num: v)
+proc toArg(v: int): Arg = Arg(isText: false, num: v.int64)
+proc toArg(v: bool): Arg = Arg(isText: false, num: ord(v).int64)
+
+proc bound(s: Store, k: Stmt, args: openArray[Arg]): PStmt =
+ result = s.stmts[k.ord]
+ discard reset(result)
+ discard clear_bindings(result)
+ for i, a in args:
+ let n = int32(i + 1)
+ if a.isText:
+ s.check bind_text(result, n, a.text.cstring, a.text.len.int32, SQLITE_TRANSIENT)
+ else:
+ s.check bind_int64(result, n, a.num)
+
+proc exec(s: Store, k: Stmt, args: varargs[Arg, toArg]): int =
+ ## Runs a statement that returns no rows; answers the rows it changed.
+ let st = s.bound(k, args)
+ try:
+ s.check step(st)
+ result = changes(s.db).int
+ finally:
+ discard reset(st)
+
+proc count(s: Store, k: Stmt, args: varargs[Arg, toArg]): int =
+ let st = s.bound(k, args)
+ try:
+ if s.check(step(st)) == SQLITE_ROW: result = column_int64(st, 0).int
+ finally:
+ discard reset(st)
+
+proc now(): int64 = getTime().toUnix()
+
+# Lists
+
+proc newListId*(): string =
+ ## 128 random bits from the OS, as 32 hex characters.
+ for b in urandom(16): result.add b.toHex(2).toLowerAscii
+
+proc isWellFormedListId*(id: string): bool =
+ id.len == 32 and id.allCharsInSet({'0'..'9', 'a'..'f'})
+
+proc listExists*(s: Store, id: string): bool =
+ isWellFormedListId(id) and s.count(sListExists, id) == 1
+
+proc createList*(s: Store): string =
+ result = newListId()
+ discard s.exec(sCreateList, result, now())
+
+# Todos
+
+type Filter* = enum
+ fAll = "all", fActive = "active", fCompleted = "completed"
+
+proc todos*(s: Store, list: string, filter = fAll): seq[Todo] =
+ let st = s.bound(sTodos, [toArg(list), toArg(filter != fAll),
+ toArg(filter == fCompleted)])
+ try:
+ while s.check(step(st)) == SQLITE_ROW:
+ result.add Todo(id: column_int64(st, 0), title: $column_text(st, 1),
+ completed: column_int64(st, 2) != 0)
+ finally:
+ discard reset(st)
+
+proc counts*(s: Store, list: string): tuple[total, completed: int] =
+ (s.count(sCountAll, list), s.count(sCountCompleted, list))
+
+proc add*(s: Store, list, title: string) =
+ discard s.exec(sAdd, list, title, now())
+
+proc toggle*(s: Store, list: string, id: int64): bool =
+ ## False when `id` isn't in `list`.
+ s.exec(sToggle, list, id) == 1
+
+proc rename*(s: Store, list: string, id: int64, title: string): bool =
+ s.exec(sRename, list, id, title) == 1
+
+proc destroy*(s: Store, list: string, id: int64): bool =
+ s.exec(sDestroy, list, id) == 1
+
+proc toggleAll*(s: Store, list: string) =
+ ## Completes every item, or reopens them all if all were already done.
+ let (total, completed) = s.counts(list)
+ discard s.exec(sSetAll, list, total == 0 or completed < total)
+
+proc clearCompleted*(s: Store, list: string) =
+ discard s.exec(sClearCompleted, list)
diff --git a/apps/nim/src/views.nim b/apps/nim/src/views.nim
new file mode 100644
index 0000000..e84bc49
--- /dev/null
+++ b/apps/nim/src/views.nim
@@ -0,0 +1,30 @@
+## HTML rendering. The markup is TodoMVC's own template (shared/reference.html)
+## plus the forms and links that make it work without JavaScript.
+
+import std/xmltree
+import store
+
+type View* = object
+ todos*: seq[Todo] ## the items the filter shows
+ total*, completed*: int ## counts over the whole list
+ filter*: Filter
+ editing*: int64 ## the item being edited without JavaScript, or 0
+ version*: string
+
+proc h(s: string): string = xmltree.escape(s)
+
+proc filterPath*(f: Filter): string =
+ case f
+ of fAll: "/"
+ of fActive: "/active"
+ of fCompleted: "/completed"
+
+proc checkedIf(b: bool): string = (if b: " checked" else: "")
+
+proc classes(completed, editing: bool): string =
+ if completed and editing: " class=\"completed editing\""
+ elif completed: " class=\"completed\""
+ elif editing: " class=\"editing\""
+ else: ""
+
+include "page.nimf"
diff --git a/apps/nim/static/app.css b/apps/nim/static/app.css
new file mode 100644
index 0000000..5ca055a
--- /dev/null
+++ b/apps/nim/static/app.css
@@ -0,0 +1,62 @@
+/* The controls that work without JavaScript, laid over TodoMVC's own. With
+ JavaScript on (html.js) they're hidden and the page is the reference. */
+
+form.inline {
+ display: contents;
+}
+
+.toggle-button,
+.toggle-all-button {
+ position: absolute;
+ z-index: 1;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 0;
+ cursor: pointer;
+}
+
+.todo-list li .toggle-button {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+}
+
+.toggle-all-button {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.todo-list li .edit-link {
+ position: absolute;
+ top: 0;
+ right: 54px;
+ bottom: 0;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .edit-link:hover,
+.todo-list li .edit-link:focus {
+ color: #484848;
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 90px;
+}
+
+.js .toggle-button,
+.js .toggle-all-button,
+.js .edit-link {
+ display: none;
+}
diff --git a/apps/nim/static/app.js b/apps/nim/static/app.js
new file mode 100644
index 0000000..082bb0c
--- /dev/null
+++ b/apps/nim/static/app.js
@@ -0,0 +1,124 @@
+// Progressive enhancement for the server-rendered TodoMVC. Every action is
+// the same form POST the page makes without JavaScript; with the
+// `X-Fragment: 1` header the server answers with the re-rendered
+// `section.main` and `footer.footer` instead of a 303, and we swap them in.
+
+const app = document.querySelector('.todoapp');
+const filter = app.dataset.filter;
+const newTodo = app.querySelector('.new-todo');
+
+// Requests run one at a time, so responses apply in the order they were made.
+let queue = Promise.resolve();
+
+function post(action, fields) {
+ const body = new URLSearchParams({ ...fields, filter });
+ const run = async () => {
+ const res = await fetch(action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Fragment': '1' },
+ credentials: 'same-origin',
+ });
+ // The item is gone (another tab removed it): show the list as it is.
+ if (!res.ok) return location.reload();
+ render(await res.text());
+ };
+ queue = queue.then(run, run);
+ return queue;
+}
+
+// True while render() swaps the list, so the edit field it removes isn't
+// taken for a blur that saves.
+let rendering = false;
+
+function render(html) {
+ // An edit in progress survives a response to some other action: carry
+ // its text, cursor and focus over to the same item in the new list.
+ const editing = app.querySelector('.todo-list li.editing');
+ const input = editing?.querySelector('.edit');
+ const kept = editing && {
+ id: editing.dataset.id,
+ value: input.value,
+ focused: document.activeElement === input,
+ start: input.selectionStart,
+ end: input.selectionEnd,
+ };
+ rendering = true;
+ try {
+ app.querySelector('.main')?.remove();
+ app.querySelector('.footer')?.remove();
+ app.querySelector('.header').insertAdjacentHTML('afterend', html);
+ } finally {
+ rendering = false;
+ }
+ const li = kept && app.querySelector(`.todo-list li[data-id="${kept.id}"]`);
+ if (!li) return;
+ const edit = li.querySelector('.edit');
+ li.classList.add('editing');
+ edit.value = kept.value;
+ if (kept.focused) {
+ edit.focus();
+ edit.setSelectionRange(kept.start, kept.end);
+ }
+}
+
+function itemOf(el) {
+ return el.closest('.todo-list li');
+}
+
+// Forms: adding, deleting and clearing completed.
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ const fields = Object.fromEntries(new FormData(form));
+ delete fields.filter;
+ post(form.getAttribute('action'), fields);
+ if (form.contains(newTodo)) newTodo.value = '';
+});
+
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle')) {
+ post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {});
+ } else if (e.target.matches('.toggle-all')) {
+ post('/todos/toggle-all', {});
+ }
+});
+
+// Editing: double-click a label; Enter or blur saves, Escape cancels.
+app.addEventListener('dblclick', (e) => {
+ if (!e.target.matches('.todo-list label')) return;
+ const li = itemOf(e.target);
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.value = e.target.textContent;
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+function finishEditing(input, save) {
+ const li = itemOf(input);
+ if (!li?.classList.contains('editing')) return;
+ li.classList.remove('editing');
+ // A page opened from an edit link (?edit=<id>) is done with it now.
+ if (location.search) history.replaceState(null, '', location.pathname);
+ if (save) {
+ post(`/todos/${li.dataset.id}/edit`, { title: input.value });
+ } else {
+ input.value = li.querySelector('label').textContent;
+ }
+}
+
+app.addEventListener('keydown', (e) => {
+ if (!e.target.matches('.todo-list .edit')) return;
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ finishEditing(e.target, true);
+ } else if (e.key === 'Escape') {
+ finishEditing(e.target, false);
+ }
+});
+
+app.addEventListener('focusout', (e) => {
+ if (rendering) return;
+ if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true);
+});
diff --git a/apps/nim/tests/test_app.nim b/apps/nim/tests/test_app.nim
new file mode 100644
index 0000000..3d0bdf7
--- /dev/null
+++ b/apps/nim/tests/test_app.nim
@@ -0,0 +1,204 @@
+## Unit tests: the store on its own, then the routes with fake requests.
+## Run with `make test`.
+
+import std/[unittest, os, tempfiles, strutils, uri]
+include "../src/app.nim"
+
+let dir = createTempDir("todo-nim-test", "")
+var dbCount = 0
+
+proc freshStore(): Store =
+ inc dbCount
+ store.open(dir / ("t" & $dbCount & ".db"))
+
+suite "store":
+ test "keeps each list to itself":
+ let s = freshStore()
+ let a = s.createList()
+ let b = s.createList()
+ s.add(a, "mine")
+ let id = s.todos(a)[0].id
+ check s.todos(b).len == 0
+ check not s.toggle(b, id)
+ check not s.rename(b, id, "theirs")
+ check not s.destroy(b, id)
+ check s.todos(a) == @[Todo(id: id, title: "mine", completed: false)]
+
+ test "filters, toggles all and clears completed":
+ let s = freshStore()
+ let l = s.createList()
+ for t in ["a", "b", "c"]: s.add(l, t)
+ check s.toggle(l, s.todos(l)[1].id)
+ check s.todos(l, fActive).len == 2
+ check s.todos(l, fCompleted).len == 1
+ s.toggleAll(l)
+ check s.counts(l) == (total: 3, completed: 3)
+ s.toggleAll(l)
+ check s.counts(l) == (total: 3, completed: 0)
+ discard s.toggle(l, s.todos(l)[0].id)
+ s.clearCompleted(l)
+ check s.todos(l).len == 2
+
+ test "keeps any Unicode and long titles exactly":
+ let s = freshStore()
+ let l = s.createList()
+ let long = "x".repeat(10_000)
+ s.add(l, "naïve 🚀 日本語 <b>")
+ s.add(l, long)
+ check s.todos(l)[0].title == "naïve 🚀 日本語 <b>"
+ check s.todos(l)[1].title == long
+
+ test "reopening runs no migration twice and keeps data":
+ let path = dir / "reopen.db"
+ var s = store.open(path)
+ let l = s.createList()
+ s.add(l, "kept")
+ s.close()
+ s = store.open(path)
+ check s.todos(l)[0].title == "kept"
+ check s.schemaVersion == 1
+ check fileExists(path & "-wal")
+ s.close()
+
+ test "list ids are 128 random bits":
+ let a = newListId()
+ check a.len == 32 and isWellFormedListId(a)
+ check a != newListId()
+ check not isWellFormedListId("%%%")
+
+# Routes
+
+let app = App(store: freshStore(), origin: "http://127.0.0.1:9", secure: false)
+
+proc request(meth: HttpMethod, target: string, body = "",
+ headers: openArray[(string, string)] = []): Request =
+ Request(reqMethod: meth, url: parseUri(target), body: body,
+ headers: newHttpHeaders(headers))
+
+proc headerOf(r: Reply, name: string): string =
+ for (k, v) in r.headers:
+ if k.cmpIgnoreCase(name) == 0: return v
+
+proc cookieOf(r: Reply): string =
+ r.headerOf("Set-Cookie").split(';')[0]
+
+proc post(target, body: string, cookie: string,
+ extra: openArray[(string, string)] = []): Reply =
+ app.route(request(HttpPost, target, body, @{"Cookie": cookie} & @extra))
+
+suite "routes":
+ test "the first visit gets a cookie and an empty page":
+ let r = app.route(request(HttpGet, "/"))
+ check r.code == Http200
+ let c = r.headerOf("Set-Cookie")
+ check c.startsWith("todo_list=") and "HttpOnly" in c and "SameSite=Lax" in c
+ check "Secure" notin c
+ check "<section class=\"main\">" notin r.body
+ check "Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> · Nim" in r.body
+
+ test "a garbled or unknown cookie gets a fresh list":
+ for bad in ["%%%", "0123456789abcdef0123456789abcdef"]:
+ let r = app.route(request(HttpGet, "/", headers = {"Cookie": "todo_list=" & bad}))
+ check r.code == Http200
+ check r.cookieOf() != "todo_list=" & bad
+
+ test "actions answer 303 back to the filter":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ for (filter, path) in [("all", "/"), ("active", "/active"),
+ ("completed", "/completed"), ("bogus", "/"), ("", "/")]:
+ let r = post("/todos", "title=x&filter=" & filter, cookie)
+ check r.code == Http303
+ check r.headerOf("Location") == path
+
+ test "titles are trimmed, blank ones ignored, and shown as text":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=" & encodeUrl(" ") & "&filter=all", cookie)
+ discard post("/todos", "title=" & encodeUrl(" <img src=x> & \"q\" "), cookie)
+ let page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check page.count("<li data-id=") == 1
+ check "<label>&lt;img src=x&gt; &amp; &quot;q&quot;</label>" in page
+
+ test "titles are trimmed of Unicode whitespace too":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ let list = cookie.split('=')[1]
+ let spaces = "\u00A0\u3000\u2003\uFEFF"
+ discard post("/todos", "title=" & encodeUrl(spaces), cookie)
+ check app.store.todos(list).len == 0
+ discard post("/todos", "title=" & encodeUrl(spaces & "naïve 🚀" & spaces), cookie)
+ check app.store.todos(list)[0].title == "naïve 🚀"
+ let id = $app.store.todos(list)[0].id
+ discard post("/todos/" & id & "/edit", "title=" & encodeUrl("\u00A0\u00A0"), cookie)
+ check app.store.todos(list).len == 0
+
+ test "a title that isn't valid UTF-8 answers 400 and adds nothing":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ let list = cookie.split('=')[1]
+ for bad in ["%80", "%C3", "%E2%82", "%F0%9F%9A", "a%FFb"]:
+ check post("/todos", "title=" & bad, cookie).code == Http400
+ check app.store.todos(list).len == 0
+ discard post("/todos", "title=ok", cookie)
+ let id = $app.store.todos(list)[0].id
+ check post("/todos/" & id & "/edit", "title=%80", cookie).code == Http400
+ check app.store.todos(list)[0].title == "ok"
+
+ test "another list's id answers 404 and changes nothing":
+ let mine = app.route(request(HttpGet, "/")).cookieOf()
+ let theirs = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=mine", mine)
+ let list = mine.split('=')[1]
+ let id = app.store.todos(list)[0].id
+ for verb in ["toggle", "destroy", "edit"]:
+ check post("/todos/" & $id & "/" & verb, "title=x", theirs).code == Http404
+ check post("/todos/nope/toggle", "", mine).code == Http404
+ check app.store.todos(list)[0] == Todo(id: id, title: "mine", completed: false)
+
+ test "a foreign Origin is refused; our own and none are allowed":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ check post("/todos", "title=a", cookie, {"Origin": "https://evil.example"}).code == Http403
+ check post("/todos", "title=a", cookie, {"Origin": "http://127.0.0.1:9"}).code == Http303
+ check post("/todos", "title=a", cookie).code == Http303
+
+ test "editing: the edit view, renaming, and blank deletes":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=old", cookie)
+ let list = cookie.split('=')[1]
+ let id = $app.store.todos(list)[0].id
+ let page = app.route(request(HttpGet, "/active?edit=" & id,
+ headers = {"Cookie": cookie})).body
+ check "<li class=\"editing\" data-id=\"" & id & "\">" in page
+ check "action=\"/todos/" & id & "/edit\"" in page
+ discard post("/todos/" & id & "/edit", "title=" & encodeUrl(" new "), cookie)
+ check app.store.todos(list)[0].title == "new"
+ discard post("/todos/" & id & "/edit", "title=+++", cookie)
+ check app.store.todos(list).len == 0
+
+ test "the page's JavaScript gets a fragment instead of a redirect":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ let r = post("/todos", "title=one&filter=active", cookie, {"X-Fragment": "1"})
+ check r.code == Http200
+ check r.body.strip.startsWith("<section class=\"main\">")
+ check "<strong>1</strong> item left" in r.body
+ check "<a class=\"selected\" href=\"/active\">" in r.body
+
+ test "counter, toggle-all state and clear-completed":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=a", cookie)
+ discard post("/todos", "title=b", cookie)
+ var page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check "<strong>2</strong> items left" in page
+ check "clear-completed" notin page.split("<footer class=\"footer\">")[1].split("</footer>")[0]
+ discard post("/todos/toggle-all", "", cookie)
+ page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check "<strong>0</strong> items left" in page
+ check "class=\"toggle-all\" type=\"checkbox\" checked" in page
+ check "<button class=\"clear-completed\">Clear completed</button>" in page
+
+ test "static files, health and version":
+ check app.route(request(HttpGet, "/static/base.css")).body == readFile("../../shared/base.css")
+ check app.route(request(HttpGet, "/static/index.css")).body == readFile("../../shared/index.css")
+ check app.route(request(HttpGet, "/healthz")).code == Http200
+ check app.route(request(HttpGet, "/api/version")).body == appVersion
+ check app.route(request(HttpGet, "/nope")).code == Http404
+
+app.store.close()
+removeDir(dir)
diff --git a/apps/nim/todo.nimble b/apps/nim/todo.nimble
new file mode 100644
index 0000000..b3f9232
--- /dev/null
+++ b/apps/nim/todo.nimble
@@ -0,0 +1,13 @@
+# Package
+
+version = "0.1.0"
+author = "CARLOS bakeoff"
+description = "TodoMVC, server-rendered, on CARLOS"
+license = "MIT"
+srcDir = "src"
+bin = @["app"]
+
+# Dependencies
+
+requires "nim >= 2.2.0"
+requires "db_connector#29450a2063970712422e1ab857695c12d80112a6"