quadrant_server 2.6.0
quadrant_server: ^2.6.0 copied to clipboard
A Dart-first, batteries-included HTTP server framework built on dart:io with zero external dependencies.
2.6.0 #
Before you upgrade #
Nothing needs changing for most apps, but these can change what a client sees:
multipartParser()now has limits it did not have before. An upload that does not fit inmaxBufferedBytes(256 MB) gets a503, and a request overmaxParts(1,000) ormaxFieldBytes(10 MB) gets a413. Set any of them to0to turn it off.multipartParser()answers400for a body without its closing delimiter, which it used to accept.Field.string()answers422for a value that is not a string, such as42, which it used to accept. UseField.int_()for a number, or send it as text.- Global middleware now runs for requests that match no route. See Changed below.
Security #
rateLimit()could be bypassed over IPv6 — Each address had its own count, and a single client usually controls a whole /64. IPv6 clients are now grouped by /64, and IPv4-mapped addresses count as the IPv4 address they carry. This only affects the default key, not akeyExtractor.rateLimit()could use unlimited memory — A flood of distinct keys all stayed in memory until the window passed. SeemaxKeysbelow.- The streaming example in the README and in the
StreamResponsedocs allowed path traversal — It built a file path fromreq.params, and params are URL-decoded, so/download/..%2Fsecret.txtcould read files outside the intended directory. The examples are fixed. If you copied that example, check your code: usestaticFiles()for files chosen by the client.
Added #
multipartParser(diskThreshold:, tempDirectory:)— Files above the threshold go to a temporary file, deleted once the response is ready. AddsUploadedFile.moveTo(),openRead(),readAsBytes(),isInMemoryandpath. By default files stay in memory andbytesworks as before; withdiskThreshold, it throws for a file on disk.multipartParser(maxBufferedBytes:, maxParts:, maxFieldBytes:)— Limits on the upload data one isolate holds in memory (256 MB), the parts in a request (1,000) and the text fields in a request (10 MB). Check these if you accept very large uploads in memory or forms with many parts: they answer503and413, and0turns them off.rateLimit(maxKeys:)— The most clients tracked at once. Defaults to 100,000.QuadrantCluster— Runs one server per isolate on the same port. In the included benchmark, a typical middleware stack went from about 45,000 to 121,000 requests per second on 8 isolates, and handlers with 1 ms of CPU work scaled 7.5x. See the README for what is and is not shared between isolates.- Restarting isolates — A
QuadrantClusterisolate that dies is replaced, with a growing delay, giving up aftermaxRestartsfailures in a row. AddsmaxRestarts,restartDelay,onIsolateExitandrunningIsolates. WebSocketGroup(name:)andWebSocketGroup.local()— Named groups broadcast across the isolates of a cluster. Outside a cluster nothing changes, and existingWebSocketGroup()calls still work.Field.object()andListField.of()— Nested validation, with errors keyed by path such asaddress.cityandscores[2].req.context['validated']— Aftervalidate(), holds the declared fields coerced to their types.req.bodyis unchanged.- Schemas in the API docs — A
validate()on a route's ownmiddlewaresnow appears as its request body at/quadrant_docs. rateLimit(trustedProxyHops:)andipFilter(trustedProxyHops:)— Read the client IP from theX-Forwarded-Forentries your own proxies appended, so behind a proxy clients no longer share one limit or get judged by the proxy's IP.ipFilter()denies a request if that IP is missing or is not a plain address.- CI — A workflow runs the analyzer and tests on pushes to
mainand on pull requests. listen(shared:),listenSecure(shared:)andQuadrantServer.requestCount.benchmark/— A repeatable load test comparing the framework with plaindart:io, raw sockets and a bare C++ server, with an HTML report.
Changed #
multipartParser()parses uploads as they arrive — A file overmaxFileSizeis rejected as soon as it passes the limit, not after the whole body has been read. A line that starts with the delimiter but continues with something else now gets a400; the multipart format does not allow one inside a part. A disk error is a server error, not a400.- Global middleware now runs for requests that match no route —
404,405and malformed-path responses now get headers fromhelmet()andcors(), andlogger()records them. Check this if you rely on the old behaviour: a globalbearerAuth()now answers401instead of404for unknown paths, a globalrateLimit()counts those requests, and a globalipFilter()blocks them.
Fixed #
Field.string()accepted any JSON type —{"name": 42}or{"name": {"a": 1}}passedvalidate()and reached the handler unchanged, soas Stringthere answered500. It now gets a422.multipartParser()turned handler errors into400s — An exception from anything after it, including the handler, returned400 Invalid multipart bodyand skippedonError. It now reaches the usual error handling.- A
413frombodyParser(),formParser()andmultipartParser()could be lost — If the body was still arriving when it passed the limit, the connection closed with no response. If the declared size was over the limit, the whole body was read first. The response now goes out straight away, and the connection then closes.bodyParser()andformParser()also reject a declared size over the limit now. multipartParser()could take a file'snamefrom itsfilename— Whenfilenamecame first in the header, as infilename="a.txt"; name="f",fieldNamewasa.txt.multipartParser()accepted truncated uploads — A body without its closing delimiter reached the handler as if complete, with a cut-off last file. It now gets a400. Check this if a client of yours omits the closing delimiter.cors()preflight did not work — A realOPTIONSrequest got a405before any middleware ran, so browsers blocked cross-origin requests that need a preflight, such as JSONPOSTs. It now gets a204, withcors()used globally, on a route, or on aQuadrantRouter. Route auth does not run on these requests, because browsers send no credentials with a preflight.- Non-ASCII responses were corrupted or empty —
Response.ok(),validate()and any response without a charset in itscontent-typewere encoded as Latin-1. A name likeZoëarrived as invalid UTF-8, and text outside Latin-1, such as日本語or the≥invalidate()'s own error messages, produced an empty body with no error. These responses are now UTF-8, and an explicit charset is still respected. - A stream that fails to start hung the request — A
StreamResponsewhose stream was already in use left the request, and soclose(), waiting forever. The connection is now dropped. - Malformed path params answered
500— A request such as/users/%FFnow gets400, without callingonError. rateLimit()slowed down with large limits — It scanned every stored timestamp on each request. It now only touches the expired ones.close()hung on open WebSockets and SSE streams — They counted as in-flight requests but nothing ended them, soclose()waited until the clients left. It now closes WebSockets with code1001and ends SSE streams first.close()did not wait forStreamResponse— It could return while a stream was still sending, and exiting then cut the response short. Requests now count as in flight until the response is written.staticFiles()lost middleware headers — It wrote straight to the socket, so headers fromhelmet(),cors()andrequestId()were never sent. It now returns aStreamResponse.HEADrequests no longer read the file.- WebSocket callbacks lost middleware context —
ctx.request.contextwas always empty, so a WebSocket route could not see thebearerAuth()user or therequestId()value. It now holds everything the middlewares added. - Calling
close()twice while draining could leave the first call waiting forever.
Performance #
Measured with benchmark/run.dart on one isolate.
- Router — Route patterns are split once at startup and the request path once per request. An app with 100 routes went from about 28,000 to 62,000 requests per second when matching its last route. Matching rules did not change: the first registered route still wins.
requestId()— About 12% more requests per second, by making fewer calls to the secure random generator. IDs are still UUID v4 from the same generator.- Simple routes are slightly slower — Plain, JSON and small POST routes lost 2–4%. The likely cause is that each request now stays in flight until its response is written, which graceful shutdown needs, though the cost was not isolated.
Added #
Request.setContext()— Lets your own middleware pass values downstream. Before, only the built-in middlewares could:Nexttakes no arguments, and the old doc comment showingnext(req.copyWith(...))did not compile.close(timeout:)— stops waiting after a deadline and destroys the remaining connections.QuadrantRouter.ws(heartbeat:)— was only available onWebSocketRoute.
2.5.2 #
Fixed #
- Memory spikes on request bodies —
bodyParser(),formParser()andmultipartParser()collected the body in aList<int>, which takes 8 bytes per byte plus room to grow. They now useBytesBuilder, andmultipartParser()no longer copies each file twice. A 45 MB multipart upload (rejected with413by the default 10 MB file limit) used to raise process memory to about 640 MB; it now stays around 140 MB. staticFiles()loaded whole files into memory — Files are now streamed from disk. Serving a 200 MB file used to take the process to 256 MB of memory; it now stays at 12 MB.WebSocketGroupkept closed connections — A closed connection stayed in its group until the nextbroadcast(), unlessonClosecalledremove(). Members now leave the group by themselves when the connection closes. To make this work, the server now closes its side of a WebSocket when the client leaves, sosocket.donecompletes. Callingremove()still works.- Errors in user callbacks crashed the server — An exception thrown in a WebSocket
onMessageoronClosecallback, or in the server's ownonError, ended the whole process. WebSocket callback errors now go to the route'sonError(or are printed). A throwingonErrornow produces a plain500. A throwingonCloseno longer makesclose()hang. IfonStartthrows, the socket is now closed with code1011; before, it stayed open with nobody listening. - Malformed query strings crashed the server — A request such as
/?a=%FF(invalid UTF-8) threw before any handler ran, so any client could stop the process. Such bytes are now decoded asU+FFFD.
Documentation #
SseContext.from(keepAliveInterval:)— Disabling the keep-alive pings means a client that went away is only noticed on the nextsend(), so the handler stays in memory until then. This is now documented.
2.5.1 #
Security #
- Swagger UI SRI — The
integrityvalues shipped in 2.5.0 for/quadrant_docswere malformed and the CDN URL used a floating@5tag. The page now pinsswagger-ui-dist@5.33.1with valid SHA-384 hashes. staticFiles()— The root-boundary check now requires a path separator, so a sibling directory whose name starts with the root's name (for example/srv/public-privatefor root/srv/public) is no longer reachable.multipartParser()— NewmaxTotalBytesoption (default 50 MB) caps the whole request body, checked againstContent-Lengthand again while streaming. Previously only per-file size was checked, after the full body had been buffered.
Fixed #
- Dropped cookies —
cors()andrateLimit()no longer discardSet-Cookievalues set withResponse.withCookie(). StreamResponsethrough middleware —helmet(),cors(),requestId()andrateLimit()no longer replace aStreamResponsewith an empty body.compress()middleware order — Compressed bodies now survive outer middlewares. Previouslycompress()only worked when it was the first middleware; otherwise clients received a gzip header with an empty body.bodyParser()/formParser()413 path — Removedreq.raw.drain()calls that ran after the request stream was already cancelled.rateLimit()— Idle clients are now evicted from memory. The docs now call it a sliding-window limiter (it never used a token bucket).
Added #
Response.copyWith(), overridden byStreamResponse;StreamResponsenow acceptscookies.multipartParser(maxTotalBytes: ...).
Changed #
compress()now returns aStreamResponse. The internalconsumeCompressedBodyhelper was removed.
2.5.0 #
Security & Hardening #
Response.redirect()validation — Replacedassertwith runtimeArgumentErrorvalidation so invalid status codes throw in release mode.bodyParser()error propagation — Narrowed body parser try-catch toon FormatExceptionso low-level socket and connection errors are not swallowed. AddedmaxBodyBytesguard to defend against body-size DoS (returns413 Payload Too Large).cors()preflight caching — AddedmaxAgeparameter (defaults to 86,400s) emittingAccess-Control-Max-Ageheader on OPTIONS preflights to prevent repeated OPTIONS round-trips.docs.dartCDN asset integrity — Added Subresource Integrity (SRI) hashes (integrity&crossorigin) to Swagger UI CDN scripts and stylesheets.- Static files path traversal security — Path resolution in
staticFiles()now normalizes paths with URI semantics to prevent directory traversal escapes (..). docsLocalOnlyoption wiring — Fixed constructor parameter passthrough inQuadrantServersodocsLocalOnly: falseis honored when intentionally deployed behind a proxy.
Added #
helmet()middleware — Injects 8 essential HTTP security headers (X-Content-Type-Options,X-Frame-Options,Referrer-Policy, etc.).requestId()middleware — Generates and forwardsX-Request-ID(UUID v4) for distributed request tracing, exposed inreq.context['requestId'].rateLimit()middleware — Sliding-window rate limiting per client IP or custom key withX-RateLimit-*andRetry-Afterheaders.bearerAuth()middleware — Extracts bearer token, validates against async callback, and injects principal intoreq.context['user'].ipFilter()middleware — Allow-list or deny-list client IP addresses supporting exact match and IPv4/IPv6 CIDR ranges.cookieParser()middleware & Response cookie helpers — ParsesCookieheader intoreq.context['cookies']; addedResponse.withCookie()andResponse.clearCookie()withSet-Cookiegeneration.formParser()middleware — Decodesapplication/x-www-form-urlencodedpayloads intoreq.bodywith configurable byte limits.multipartParser()middleware — Handles multipart file uploads and form fields, exposing typedUploadedFileinstances inreq.context['files'].compress()middleware — Response gzip compression usingdart:ioGZipCodecwithAccept-Encodingnegotiation.etag()middleware — Generates weak ETags and handlesIf-None-Matchconditional requests returning304 Not Modified.SseContext— Push-based Server-Sent Events (SSE) compatible with browserEventSourceand automatic keep-alive comment pings.StreamResponse— Response subclass for streaming raw bytes directly to client sockets without memory buffering.- Request validation DSL —
BodySchemaandField(string,int_,double_,bool_,list) withvalidate()middleware returning422 Unprocessable Entity. listenSecure()— First-class HTTPS/TLS binding withcertificateChainandprivateKeyfiles.- Health check endpoint — Optional built-in
/healthreporting uptime, request counts, and RSS memory usage. - WebSocket heartbeats — Configurable
heartbeatduration onWebSocketRoutefor automatic ping/pong keepalive. - Graceful in-flight request draining —
app.close()drains active requests before terminating. timeout(Duration, {onTimeout})middleware — Races the handler chain against a deadline; returns408with{"error":"Request Timeout","timeoutMs":…}or youronTimeoutresponse.staticFiles(rootDir, {indexFallback})route handler — MIME detection,ETag/Last-Modifiedwith304forIf-None-Match/If-Modified-Since,Cache-Control: public, max-age=3600,index.htmlfallback.healthCheckPathoption — Default/health. The endpoint is answered before middleware runs, so it skips auth, rate limiting and logging.app.close({force})—force: trueskips draining in-flight requests.- TLS/binding options —
listenSecure(password:)for encrypted keys;listen/listenSecureacceptaddress. - Validation rules —
required,minLength,maxLength,matches(RegExp),email,oneOf,min,max,minItems,maxItems. String inputs are coerced for int/double/bool fields. Failures return422 {"errors": {field: message}}. SseContextAPI —send(id:, retry:),sendComment(),close(),isOpen,keepAliveInterval(default 15 s,Duration.zerodisables),X-Accel-Buffering: no.WebSocketGroup—exclude:onbroadcast/broadcastJson, plusisEmpty,isNotEmpty,length,members.- Middleware options —
bearerAuth(contextKey, realm)withWWW-Authenticateon 401;ipFilter(onDeny);multipartParser(maxFileSize, filesContextKey, fieldsContextKey)returning413for oversized files;bodyParser/formParseracceptmaxBodyBytes: 0to disable the limit; all 8helmet()headers are overridable andnullremoves one.
Fixed #
- WebSocket upgrade error handling — Upgrades failing in
onErrornow return an HTTP error response to the client rather than leaving the connection hanging. - WebSocket middleware chain sentinel — Replaced dummy 200 response with
Response(204)sentinel. - Connection close resilience — Guarded
_writeResponseagainst writing to already-closed HTTP sockets. - Banner ASCII padding — Handled high route counts safely with
max(0, ...).
2.0.0 #
Added #
QuadrantRouter— prefix-mounted route grouping; organise large apps without repeating path prefixes. Supports router-level middlewares applied to all routes in the group.WebSocketGroup— built-in broadcast/room utility.add(),remove(),broadcast(),broadcastJson(),closeAll()with lazy cleanup of stale connections.WebSocketContext.isOpen/isClosed— check connection state before sending to avoid throwing on a closed socket.WebSocketContext.sendJson()— convenience JSON-encode-and-send shortcut.- Wildcard route segments —
Route.get(path: '/files/*', ...)captures the full remaining path asreq.params['*']. Response.redirect()— 301/302/307/308 redirect withlocationheader.Response.text()— plain-text response withcontent-type: text/plain.Response.html()— HTML response withcontent-type: text/html.Response.conflict()— 409 Conflict.Response.unprocessableEntity()— 422 Unprocessable Entity.- Typed query helpers on
Request—queryString(),queryInt(),queryDouble(),queryBool()with default value support. Request.bodyAsMap/bodyAsList— safe typed casts for the parsed body.app.close()— graceful shutdown that waits for in-flight requests.docsLocalOnlyparameter — control whether/quadrant_docsis loopback-only (default:true).logger()output sink — injectable log destination:logger(output: myLogger.info).cors()named parameters —methodsandallowedHeadersnow configurable.- Startup banner — printed when
docs: trueshowing host, route counts, and docs URL. - Unit test suite (
test/quadrant_server_test.dart) covering router, response factories, QuadrantRouter, and middleware. analysis_options.yamlwithlints/recommendedand strict type-checking.
Fixed #
RequestHolderhash collision — replacedhashCode-basedMapkey withExpando<Object>keyed on theHttpRequestobject itself. Eliminates body cross-contamination under concurrent load.bodyParsersilently dropped JSON array bodies —req.bodyis nowdynamicand accepts top-levelListpayloads.- Plain-string responses lacked
Content-Type— all string response bodies now carrycontent-type: text/plain; charset=utf-8. - WebSocket upgrade errors silently swallowed — upgrade failures now delegate to
onErroror print a warning. /quadrant_docsaccessible from external IPs — loopback guard is now active by default (docsLocalOnly: true).internalServerErrorleaked raw exception messages — default error handler now returns a generic safe message; users opt in to detail viaonError.
Improved #
cors()now setsVary: Originwhenorigin != '*'for correct CDN/proxy caching behaviour.WebSocketContext.send()is now a no-op when the socket is closed instead of throwing.
1.2.0 #
Added #
- WebSocket support — declarative
WebSocketRoutewith four named callbacks (onStart,onMessage,onClose,onError) WebSocketContext— per-connection context withsend(),close(), and access to the original upgrade request (params, query, headers)webSocketRoutesparameter onQuadrantServer— declare WS endpoints alongside HTTP routes- Middleware support for WebSocket — route-level middlewares run before upgrade; rejection prevents the upgrade entirely
- Path parameters on WS routes —
/ws/chat/:roomIdextracts params just like HTTP routes MatchedWebSocketsealed type — router distinguishes WS upgrades from HTTP requests at the type level- Docs: WebSocket section —
/quadrant_docsnow renders WS endpoints in a table below Swagger UI
Improved #
- Router —
match()acceptsisUpgradeRequestflag; WS upgrades never fall through to HTTP GET routes
1.1.1 #
Added #
/quadrant_docsendpoint — opt-in interactive API explorer powered by Swagger UI (CDN-loaded), enabled viadocs: true- 405 Method Not Allowed — router now distinguishes "path exists, wrong method" from "path not found", returns proper
Allowheader - HEAD request support — HEAD automatically falls back to GET handlers per HTTP spec
- Trailing slash normalization —
/users/and/usersnow match the same route - URI-decoded path params —
%20and other encoded characters decoded automatically Route.paramNamesgetter — extracts parameter names from path pattern for introspection
Improved #
- Router performance — routes grouped by HTTP method for faster lookup (O(n/m) vs O(n))
- Method normalization —
'get'and'GET'both resolve correctly
Security #
/quadrant_docslocked to loopback addresses only — external IPs always get 404- Docs route excluded from generated OpenAPI spec (no self-referencing)
1.0.0 #
- Initial release
- Core framework:
QuadrantServer,Request,Response,Route,Router - Middleware system with global and route-level support
- Built-in middlewares:
cors(),logger(),bodyParser() - Path parameter extraction (
:paramsegments) - Immutable response pattern with static constructors
- Zero external dependencies — built entirely on
dart:io