carlos / todomvc-bakeoff Public
feat: nim builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+1727
−0
apps/nim/AGENTS.md+35 −0apps/nim/DEPS+2 −0apps/nim/Makefile+77 −0apps/nim/README.md+97 −0apps/nim/STATUS.md+90 −0apps/nim/src/app.nim+256 −0apps/nim/src/httpserver.nim+454 −0apps/nim/src/page.nimf+75 −0apps/nim/src/store.nim+208 −0apps/nim/src/views.nim+30 −0apps/nim/static/app.css+62 −0apps/nim/static/app.js+124 −0apps/nim/tests/test_app.nim+204 −0apps/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><img src=x> & "q"</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" |