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.
QuadrantServer #
A batteries-included HTTP server framework for Dart, built directly on dart:io with no runtime dependencies.
Handlers take a Request and return a Response. Routes, middleware, WebSockets and server options are all declared up front, so you can read how a server behaves in one place.
final app = QuadrantServer(
middlewares: [helmet(), logger(), bodyParser()],
routes: [
Route.get(path: '/users/:id', handler: getUser),
Route.post(path: '/users', handler: createUser, middlewares: [validate(userSchema)]),
],
);
What's included #
| Area | Features |
|---|---|
| Routing | Path params, wildcards, route groups, automatic 404, 405 and HEAD |
| Middleware | 15 built in: security headers, CORS, auth, rate limiting, body parsing, streaming uploads, compression, caching and more |
| Real-time | WebSockets with rooms and heartbeats, Server-Sent Events |
| Validation | Schemas with nested objects, typed values and field-level errors |
| Multi-core | One server per isolate on a single port, with rate limits and broadcasts shared between them, and restarts for isolates that die |
| Operations | TLS, graceful shutdown, health check, generated Swagger UI docs, streaming responses |
Contents #
- Installation
- Quick start
- Routes
- Requests and responses
- Middleware
- Request validation
- Static files and streaming
- Real-time
- Using every CPU core
- Running in production
- Known limitations
- Development
Installation #
Requires Dart 3.0 or later. It needs dart:io, so it runs on servers and desktops, not on the web.
dart pub add quadrant_server
This adds the latest version, which is shown on the badge above. See the changelog for what changed in each release.
Quick start #
import 'package:quadrant_server/quadrant_server.dart';
Future<Response> getUser(Request req) async {
return Response.ok({'id': req.params['id'], 'name': 'Ada'});
}
Future<Response> createUser(Request req) async {
return Response.created(req.bodyAsMap ?? {});
}
void main() async {
final app = QuadrantServer(
middlewares: [
requestId(),
helmet(),
logger(),
bodyParser(), // Fills req.body from JSON requests, up to 1 MB
],
routes: [
Route.get(path: '/users/:id', handler: getUser),
Route.post(path: '/users', handler: createUser),
],
healthCheck: true, // GET /health
docs: true, // GET /quadrant_docs, from this machine only
);
await app.listen(port: 3000);
}
curl localhost:3000/users/42
# {"id":"42","name":"Ada"}
curl -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":"Grace"}'
# {"name":"Grace"}
The example/ directory has a larger app with authentication, CRUD routes, chat rooms and a Dockerfile.
Routes #
Route.get(path: '/users', handler: getUsers)
Route.post(path: '/users', handler: createUser)
Route.put(path: '/users/:id', handler: updateUser)
Route.patch(path: '/users/:id', handler: patchUser)
Route.delete(path: '/users/:id', handler: deleteUser)
// Any other method:
Route(method: 'OPTIONS', path: '/users', handler: usersOptions)
- Path params:
/users/:idputs the decoded value inreq.params['id']. A value that is not valid UTF-8, such as/users/%FF, gets a400. - Wildcards:
/files/*matches/files/a/band putsa/binreq.params['*']. A wildcard goes at the end of the path. - Matching order: the first registered route that matches wins, so put
/users/mebefore/users/:id. - Slashes:
/users/and/usersare the same route. HEAD: handled by the matchingGETroute if there is noHEADroute.
Route groups #
QuadrantRouter puts a shared prefix and shared middleware on a group of routes:
final admin = QuadrantRouter(
prefix: '/admin',
middlewares: [bearerAuth(validate: findAdmin)],
)
..get('/users', listUsers)
..delete('/users/:id', deleteUser)
..ws('/live', onMessage: onLiveMessage, heartbeat: Duration(seconds: 30));
final app = QuadrantServer(
routes: [...admin.routes],
webSocketRoutes: [...admin.webSocketRoutes],
);
Requests and responses #
Request #
req.method; // 'GET'
req.path; // '/users/123'
req.params; // {'id': '123'}
req.query; // {'page': '1'}
req.headers; // {'content-type': 'application/json'}, with lowercase names
req.body; // Parsed body, once a body parser has run
req.bodyAsMap; // Map<String, dynamic>?, null for anything else
req.bodyAsList; // List<dynamic>?, null for anything else
req.context; // Values added by middleware
req.raw; // The dart:io HttpRequest, if you need it
req.queryString('sort', defaultValue: 'asc');
req.queryInt('page', defaultValue: 1);
req.queryDouble('lat');
req.queryBool('active', defaultValue: true); // true/1/yes, false/0/no
req.body stays null unless bodyParser(), formParser() or similar has run.
Response #
Responses are immutable. Maps and lists are encoded as JSON, and text is sent as UTF-8 unless your content-type names another charset.
Response.ok(data) // 200
Response.created(data) // 201
Response.noContent() // 204
Response.redirect('/new-path') // 302, or pass statusCode: 301, 307 or 308
Response.text('plain text') // 200 text/plain
Response.html('<h1>Hello</h1>') // 200 text/html
Response.badRequest('message') // 400
Response.unauthorized('message') // 401
Response.forbidden('message') // 403
Response.notFound('message') // 404
Response.conflict('message') // 409
Response.unprocessableEntity('message') // 422
Response.internalServerError('message') // 500
// Anything else:
Response(statusCode: 202, headers: {'x-job': '42'}, body: 'Queued')
The body is a String. For binary data, use StreamResponse.
Cookies #
return Response.ok({'status': 'logged_in'})
.withCookie(
'session_id',
token,
secure: true,
sameSite: 'Strict',
maxAge: Duration(days: 7),
)
.withCookie('theme', 'dark', httpOnly: false);
return Response.ok({}).clearCookie('session_id');
Cookies are HttpOnly and SameSite=Lax unless you say otherwise. Read incoming ones with cookieParser().
Middleware #
Global middleware runs first, in order, followed by the route's own. A middleware either calls next() to continue or returns a Response to stop there.
Global middleware also runs for requests that match no route, so 404 and 405 responses get security headers and appear in your logs. A global bearerAuth() answers 401 for unknown paths too, which hides the routes you have, and a global rateLimit() counts those requests. Middleware on a route or QuadrantRouter only runs for that route, except that cors() also answers its preflights.
| Middleware | What it does | Options |
|---|---|---|
helmet() |
Sets 8 security headers, including HSTS | One per header; null removes it |
cors() |
Adds CORS headers and answers preflight OPTIONS requests |
origin, methods, allowedHeaders, maxAge |
requestId() |
Reuses or generates an ID, in req.context['requestId'] and the response |
headerName, contextKey |
logger() |
Logs method, path, status and time | output |
bearerAuth() |
Checks Authorization: Bearer; puts the result in req.context['user'] |
validate, contextKey, realm |
rateLimit() |
Sliding-window limit per IP or custom key | maxRequests, window, keyExtractor, trustedProxyHops, maxKeys (100,000) |
ipFilter() |
Allows or denies exact IPs and CIDR ranges | allow or deny, onDeny, trustedProxyHops |
bodyParser() |
Parses JSON into req.body; 400 if invalid, 413 if too large |
maxBodyBytes (1 MB) |
formParser() |
Parses URL-encoded forms into req.body |
maxBodyBytes (64 KB) |
multipartParser() |
Parses uploads into req.context['files'] and ['fields'] |
maxFileSize (10 MB), maxTotalBytes (50 MB), diskThreshold, maxBufferedBytes (256 MB), maxParts (1,000), maxFieldBytes (10 MB) |
cookieParser() |
Parses cookies into req.context['cookies'] |
contextKey |
compress() |
Gzips responses for clients that accept it | minBytes (512 B) |
etag() |
Adds weak ETags and answers 304 Not Modified |
None |
timeout() |
Returns 408 if the handler takes too long |
duration, onTimeout |
validate() |
Checks the body against a schema; see below | schema, contextKey |
helmet() sends HSTS by default. On a server that only uses HTTP, turn it off with helmet(hsts: null).
Writing your own #
Use req.setContext() to pass values to later middleware, the handler and WebSocket callbacks:
Future<Response> tenant(Request req, Next next) async {
final id = req.headers['x-tenant-id'];
if (id == null) return Response.badRequest('Missing tenant');
req.setContext('tenant', id);
final response = await next();
// Runs after the handler. Use copyWith so cookies and streams are kept.
return response.copyWith(
headers: {...response.headers, 'x-tenant-id': id},
);
}
Authentication #
final auth = bearerAuth(
validate: (token) => myAuthService.verify(token), // Return null to reject
);
Route.get(
path: '/me',
middlewares: [auth],
handler: (req) async {
final user = req.context['user'] as UserProfile;
return Response.ok({'id': user.id, 'name': user.name});
},
)
Rejected requests get a 401 with a WWW-Authenticate header. On a WebSocketRoute, the check runs before the upgrade, and the user is available as ctx.request.context['user'].
Rate limiting #
// 100 requests per minute, per client IP:
rateLimit(maxRequests: 100, window: Duration(minutes: 1))
// Stricter on a sensitive route:
Route.post(
path: '/auth/login',
handler: login,
middlewares: [rateLimit(maxRequests: 5, window: Duration(minutes: 15))],
)
Responses carry X-RateLimit-* headers, and a rejected request gets a 429 with Retry-After.
IPv6 clients are grouped by /64, because one client usually controls that whole range. At most maxKeys clients are tracked at once, so a flood of new keys cannot use up memory: past it, idle clients are forgotten first and then the oldest ones, whose counts start again.
Behind a proxy, every request appears to come from the proxy's IP, so all your clients would share one limit. Tell rateLimit() how many proxies you run, and it reads the client IP from the entries they appended to X-Forwarded-For:
rateLimit(maxRequests: 100, window: Duration(minutes: 1), trustedProxyHops: 1)
Leave it unset if you are not behind a proxy, or clients can pick their own IP by sending that header. ipFilter() has the same option, and it denies a request if the forwarded IP is missing or is not a plain address, rather than judging it by the proxy's IP.
File uploads #
Route.post(
path: '/upload',
middlewares: [multipartParser(maxFileSize: 5 * 1024 * 1024)],
handler: (req) async {
final files = req.context['files'] as List<UploadedFile>;
final fields = req.context['fields'] as Map<String, String>;
for (final file in files) {
// file.filename comes from the client. Never use it as a path.
final savedName = '${DateTime.now().microsecondsSinceEpoch}.bin';
await file.moveTo('uploads/$savedName');
}
return Response.ok({'uploaded': files.length, 'fields': fields.length});
},
)
Uploads are parsed as they arrive, so one that breaks a limit is rejected straight away. By default, files are held in memory until the handler finishes, and past maxBufferedBytes on an isolate, new uploads get a 503.
For big files or many uploads at once, set diskThreshold. Files bigger than that go to a temporary file instead of memory:
multipartParser(
maxFileSize: 500 * 1024 * 1024,
maxTotalBytes: 500 * 1024 * 1024,
diskThreshold: 1024 * 1024,
)
On an UploadedFile |
|
|---|---|
moveTo(path) |
Saves it. Use this to keep a temporary file, because it is deleted once the response is ready. |
openRead(), readAsBytes() |
Read it, wherever it is. |
bytes |
Only for a file in memory. It throws for a file on disk, so check isInMemory or use the others. |
Finish reading before the handler returns, and do not return a StreamResponse that reads from openRead().
maxTotalBytes limits one request, so many uploads together can still fill the disk: give tempDirectory somewhere with a limit, if that matters. If the process is killed, temporary files stay behind in quadrant_upload_* directories, so clear those when it starts.
Request validation #
final userSchema = BodySchema({
'username': Field.string().minLength(3).maxLength(20).required(),
'email': Field.string().email().required(),
'age': Field.int_().min(18).max(120).required(),
'role': Field.string().oneOf(['admin', 'member']).required(),
'address': Field.object({
'city': Field.string().required(),
'zip': Field.int_(),
}),
'scores': Field.list().maxItems(10).of(Field.int_().max(100)),
});
Route.post(
path: '/register',
handler: register,
middlewares: [validate(userSchema)], // Needs a body parser to run first
)
Invalid requests get a 422 listing every problem, with nested fields named by their path:
{"errors": {"email": "email must be a valid email address", "address.city": "address.city is required", "scores[2]": "scores[2] must be ≤ 100"}}
On success, req.context['validated'] holds only the declared fields, converted to their types. req.body stays exactly as it was received.
final user = req.context['validated'] as Map<String, dynamic>;
final age = user['age'] as int; // "42" in the request becomes 42
Schemas on a route's own middlewares also appear as its request body in the generated docs.
Static files and streaming #
Static files #
Route.get(path: '/public/*', handler: staticFiles('./public'))
Files are streamed from disk. You get path traversal protection, MIME types, ETag and Last-Modified with 304 responses, and index.html for directories.
Streaming responses #
StreamResponse sends data as it becomes available, without holding it all in memory. It is also how you send binary data.
Route.get(
path: '/reports/monthly',
handler: (req) async {
final file = File('reports/monthly.csv');
return StreamResponse(
stream: file.openRead(),
headers: {
'content-type': 'text/csv',
'content-length': '${await file.length()}',
},
);
},
)
Never build a file path from
req.params. Params are URL-decoded, so/download/..%2Fsecret.txtgives you../secret.txt. To serve files chosen by the client, usestaticFiles(), which checks for this.
If a stream fails partway, the connection is dropped so the client can tell the response is incomplete.
Real-time #
Server-Sent Events #
Works with the browser's EventSource:
Route.get(
path: '/events',
handler: (req) async {
final sse = await SseContext.from(req);
final timer = Timer.periodic(Duration(seconds: 1), (_) {
sse.send(data: 'tick', event: 'heartbeat');
});
await sse.done; // Until the client leaves or the server shuts down
timer.cancel();
return sse.response;
},
)
WebSockets #
WebSocketRoute(
path: '/ws/echo/:roomId',
heartbeat: Duration(seconds: 30), // Closes connections that stop responding
onStart: (ctx) async => ctx.sendJson({'room': ctx.request.params['roomId']}),
onMessage: (ctx, data) async => ctx.send(data),
onClose: (ctx, code, reason) async => print('Closed: $code'),
onError: (ctx, error) async => print('Failed: $error'),
)
Pass these to QuadrantServer(webSocketRoutes: [...]). Only onMessage is required. Middleware runs before the upgrade, so it can reject a connection with a normal HTTP response.
Rooms and broadcasting #
final rooms = <String, WebSocketGroup>{};
WebSocketGroup room(String id) =>
rooms.putIfAbsent(id, () => WebSocketGroup(name: 'room:$id'));
WebSocketRoute(
path: '/ws/chat/:roomId',
onStart: (ctx) async {
final group = room(ctx.request.params['roomId']!);
group.add(ctx);
group.broadcastJson({'event': 'joined'}, exclude: ctx);
},
onMessage: (ctx, data) async {
room(ctx.request.params['roomId']!).broadcast(data, exclude: ctx);
},
onClose: (ctx, code, reason) async {
final id = ctx.request.params['roomId']!;
if (room(id).isEmpty) rooms.remove(id);
},
)
Connections leave their group automatically when they close. The name is only needed in a cluster, where it links groups across isolates.
Using every CPU core #
A Dart isolate runs your handlers on one thread. QuadrantCluster runs one server per isolate on the same port:
import 'dart:io';
import 'package:quadrant_server/quadrant_server.dart';
Future<Response> getUsers(Request req) async => Response.ok([]);
void main() async {
final cluster = await QuadrantCluster.start(
isolates: Platform.numberOfProcessors,
port: 3000,
build: () => QuadrantServer(
middlewares: [
logger(),
rateLimit(maxRequests: 100, window: Duration(minutes: 1)),
],
routes: [Route.get(path: '/users', handler: getUsers)],
),
);
print('Listening on port ${cluster.port}');
// Later: await cluster.close();
}
build runs once inside each isolate. Open database connections and similar resources inside it, because they cannot be sent between isolates. For HTTPS, pass certificateChain and privateKey to start().
What is shared between isolates #
Isolates do not share memory, so a variable in your code exists once per isolate.
| Across isolates | |
|---|---|
rateLimit() |
One count. Each limited request makes one round trip to the main isolate. build must create limiters in the same order every time. If the main isolate does not answer within 250 ms, the isolate counts on its own until it does, and prints a warning. |
WebSocketGroup(name: 'room:42') |
broadcast(), broadcastJson() and closeAll() reach every isolate. length, isEmpty and members cover the current one only. |
WebSocketGroup() |
Throws when you add a member, because it would silently miss clients. Use WebSocketGroup.local() if you mean to stay on one isolate. |
| Health check | Reports the total requests of all isolates, plus an isolates count. |
| Your own variables | Not shared. Keep caches, sessions and similar state in a database or external store. |
Nothing is shared with other processes or machines, so if you run several, rate limits apply to each one separately.
When an isolate dies #
It is replaced with a new one from build. The wait starts at restartDelay (1 second) and doubles with each failure in a row, up to 30 seconds. After maxRestarts (10) failures in a row, it is given up on and the others keep serving. The count starts again once an isolate has run for a minute, and maxRestarts: 0 turns restarting off.
final cluster = await QuadrantCluster.start(
isolates: Platform.numberOfProcessors,
port: 3000,
build: buildServer,
onIsolateExit: (index, {required willRestart}) {
// Let your process supervisor take over.
if (!willRestart) exit(1);
},
);
New connections go to the isolates that are alive, and the replacement takes its share as soon as it is listening. Its share of the health check's request total starts again from 0. rateLimit() counts are kept, because they live on the main isolate. cluster.runningIsolates tells you how many are listening.
Sockets the dead isolate had stay open until the Dart VM next collects garbage, which can take a while on a quiet server. Until then its clients get no error, so set a timeout on your clients or proxy. If you close() the cluster and bind its port again in the same process, that can fail for the same reason; a process that exits releases everything.
Restarting does not replace a process supervisor. A crash of the Dart VM ends every isolate, so run under systemd, Docker or similar.
Performance #
Measured with benchmark/ on an i7-13620H laptop, using a compiled build over loopback, with no TLS, 64 keep-alive connections, and the server pinned to 4 physical cores (8 hardware threads):
| Workload | 1 isolate | 8 isolates |
|---|---|---|
| Plain route | 66,692 req/s | 175,750 req/s (2.6x) |
| 1 ms of CPU work per request | 991 req/s | 7,478 req/s (7.5x) |
requestId, helmet, cors, logger, bodyParser |
44,563 req/s | 120,550 req/s (2.7x) |
| Rate limited, count shared across isolates | 54,535 req/s | 138,860 req/s (2.5x) |
The more work your handlers do, the more isolates help. If they mostly wait on a database, one isolate may be enough: it handled 256 connections with a simulated 10 ms query at 39% of one core. These numbers are a guide, so measure your own app on your own hardware.
For the best performance, deploy a compiled build (dart compile exe) rather than using dart run.
Running in production #
For a complete app with step-by-step instructions for a VPS, including systemd, nginx and HTTPS, see example/DEPLOY.md.
HTTPS #
await app.listenSecure(
port: 443,
certificateChain: File('cert.pem'),
privateKey: File('key.pem'),
);
Health check #
QuadrantServer(healthCheck: true, routes: [...]);
// GET /health
// {"status":"ok","uptime":3600,"requests":1024,"memoryMb":38.4}
It is answered before any middleware, so it skips auth, rate limiting and logging. Anyone who can reach it can read these numbers. Change the path with healthCheckPath.
API docs #
docs: true serves Swagger UI at /quadrant_docs, generated from your routes. By default it only answers requests from the same machine; docsLocalOnly: false opens it up. The page loads Swagger UI from a CDN, so the browser viewing it needs internet access.
Graceful shutdown #
ProcessSignal.sigterm.watch().listen((_) async {
await app.close(timeout: Duration(seconds: 30));
exit(0);
});
close() stops accepting connections, ends open WebSockets (with code 1001) and SSE streams, and waits for in-flight requests and streaming responses. timeout limits that wait, so a stalled client cannot hold up shutdown; without it, the wait has no limit. close(force: true) drops everything straight away.
Error handling #
QuadrantServer(
routes: [...],
onError: (error, req) {
print('Failed on ${req.path}: $error');
return Response.internalServerError('Something went wrong');
},
);
Without onError, an exception in a handler gives a 500 with a generic message, so internal details do not reach clients. WebSocket callback errors go to that route's onError.
Checklist #
listen()binds to all network interfaces. To accept local connections only, such as behind a proxy on the same machine, passaddress: InternetAddress.loopbackIPv4.- Replace the
cors()default oforigin: '*'with your real origin. - Set
trustedProxyHopsonrateLimit()andipFilter()if you run behind a proxy. - Check that
bodyParser()andmultipartParser()limits suit your app, and setdiskThresholdif you accept big files. - Run under a process supervisor such as systemd or Docker, and set timeouts on your clients or proxy. In a cluster, consider
onIsolateExit.
Known limitations #
- A stuck cluster isolate is not detected. One in an endless loop has not died, so it is not replaced, and requests it has accepted wait.
- Connections on a cluster isolate that dies stay open until the next garbage collection. Those clients may wait until their own timeout.
rateLimit()keeps its counts in memory. They reset on restart and are not shared between processes or machines.- JSON and form bodies are held in memory until the request finishes, up to their
maxBodyBytes. So are uploads, unless you setdiskThreshold.
Development #
dart pub get
dart test
dart analyze
Benchmarks #
Linux only. Requires g++ and taskset.
dart run benchmark/run.dart --label=my-run # About 15 minutes
dart run benchmark/report.dart --after=my-run # Writes benchmark/results/report.html
The results compare the framework with plain dart:io, raw Dart sockets and a bare C++ server. The report opens in any browser and needs no network access.
Releasing #
Update the version in both pubspec.yaml and lib/src/core/version.dart; a test fails if they differ. The README has no version number to update.
License #
MIT