fix(nim): review findings: chunked body limit, Unicode trim, edits survive re-render, zig cache

- Vendor std/asynchttpserver as src/httpserver.nim and check each chunk
  size against the remaining body limit before reading it; 1 MiB limit.
- Trim titles of the whitespace JavaScript's trim removes, not just ASCII.
- Keep an in-progress edit (text, cursor, focus) across a response to
  another action.
- Point zig's global and local caches into .cache/ and remove them in
  clean-deps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6aa1f3550060b4727394933ae6fe77615ccbd12b parent 9745c91
6 files changed, +541 −19
  • apps/nim/Makefile +9 −2
  • apps/nim/README.md +10 −5
  • apps/nim/src/app.nim +23 −9
  • apps/nim/src/httpserver.nim +454 −0
  • apps/nim/static/app.js +33 −3
  • apps/nim/tests/test_app.nim +12 −0
diff --git a/apps/nim/Makefile b/apps/nim/Makefile
index 00dca3a..bb223ef 100644
--- a/apps/nim/Makefile
+++ b/apps/nim/Makefile
@@ -12,6 +12,11 @@ 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) \
@@ -52,9 +57,11 @@ clean:
rm -rf dist
# Compiled output only: downloaded sources (.cache/nimble, .cache/dl,
-# .cache/sqlite) stay, so the next build compiles every library again.
+# .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
+ 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
diff --git a/apps/nim/README.md b/apps/nim/README.md
index 634a667..257caec 100644
--- a/apps/nim/README.md
+++ b/apps/nim/README.md
@@ -17,10 +17,14 @@ JavaScript off, and keeps each visitor's list in SQLite. It runs at
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 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.
+ 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`).
@@ -59,7 +63,7 @@ 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 the compiled C under .cache/nimcache-*
+make clean-deps # also removes compiled C: .cache/nimcache-* and zig's caches
```
Run it locally and drive the shared behaviour suite against it:
@@ -86,6 +90,7 @@ make deploy MESSAGE="what changed"
| 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 |
diff --git a/apps/nim/src/app.nim b/apps/nim/src/app.nim
index b94bc8c..ade23ff 100644
--- a/apps/nim/src/app.nim
+++ b/apps/nim/src/app.nim
@@ -3,9 +3,9 @@
## app --socket <path> --db <path> (on Carloku)
## app --listen 127.0.0.1:<port> --db <path> (local runs and tests)
-import std/[asyncdispatch, asynchttpserver, asyncnet, cookies, importutils, options,
- nativesockets, net, os, parseopt, posix, strutils, strtabs, uri]
-import store, views
+import std/[asyncdispatch, asyncnet, cookies, importutils, options,
+ nativesockets, net, os, parseopt, posix, strutils, strtabs, unicode, uri]
+import httpserver, store, views
const
appVersion {.strdefine.} = "dev"
@@ -16,6 +16,9 @@ const
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
@@ -42,6 +45,17 @@ proc parseFilter(s: string): Filter =
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 = unicode.strip(s, runes = titleSpaces)
+
proc formFields(req: Request): StringTableRef =
result = newStringTable(modeCaseSensitive)
for (k, v) in decodeQuery(req.body):
@@ -109,7 +123,7 @@ proc action(app: App, req: Request, path: string): Reply =
let parts = path.split('/') # "", "todos", <id or verb>, [verb]
var found = true
if parts.len == 2:
- let title = form.getOrDefault("title").strip()
+ 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)
@@ -121,7 +135,7 @@ proc action(app: App, req: Request, path: string): Reply =
of "toggle": found = app.store.toggle(list, id)
of "destroy": found = app.store.destroy(list, id)
of "edit":
- let title = form.getOrDefault("title").strip()
+ 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")
@@ -163,7 +177,7 @@ proc handle(app: App, req: Request) {.async.} =
stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg
r = reply(Http500, "Internal error\n")
r.headers.add ("Server", serverName)
- # asynchttpserver sets Content-Length from the body, in bytes.
+ # The server sets Content-Length from the body, in bytes.
await req.respond(r.code, r.body, newHttpHeaders(r.headers))
# Startup and shutdown
@@ -186,9 +200,9 @@ proc parseArgs(): Config =
quit usage, 2
proc unixServer(path: string): AsyncHttpServer =
- ## asynchttpserver only listens on TCP; hand it a unix socket instead.
+ ## The server only listens on TCP; hand it a unix socket instead.
privateAccess(AsyncHttpServer)
- result = newAsyncHttpServer()
+ 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)
@@ -210,7 +224,7 @@ proc main() =
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()
+ server = newAsyncHttpServer(maxBody = maxBody)
server.listen(Port(port), host)
app.origin = "http://" & cfg.listen
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/static/app.js b/apps/nim/static/app.js
index 03e8fc1..082bb0c 100644
--- a/apps/nim/static/app.js
+++ b/apps/nim/static/app.js
@@ -27,10 +27,39 @@ function post(action, fields) {
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) {
- app.querySelector('.main')?.remove();
- app.querySelector('.footer')?.remove();
- app.querySelector('.header').insertAdjacentHTML('afterend', 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) {
@@ -90,5 +119,6 @@ app.addEventListener('keydown', (e) => {
});
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
index 537e066..3fa1080 100644
--- a/apps/nim/tests/test_app.nim
+++ b/apps/nim/tests/test_app.nim
@@ -118,6 +118,18 @@ suite "routes":
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 "another list's id answers 404 and changes nothing":
let mine = app.route(request(HttpGet, "/")).cookieOf()
let theirs = app.route(request(HttpGet, "/")).cookieOf()