nitro_server 1.0.1
nitro_server: ^1.0.1 copied to clipboard
Fast HTTP/1.1 and WebSocket server for Dart and Flutter, backed by a native multithreaded C++ engine over Nitro FFI, with trie routing and per-route timeouts.
nitro_server #
HTTP/1.1 + HTTP/2 + WebSocket server for Dart and Flutter. Native C++ engine
(libuv, via Nitro FFI) does accept / parse /
route / write on every core; handlers run in Dart. Client library:
nitro_http.
import 'package:nitro_server/nitro_server.dart';
Future<void> main() async {
final server = await NitroServer.bind(); // 127.0.0.1, a free port
await server.get('/hello', (_) => ResponseContext.text('hi ๐'));
await server.get('/users/:id', (r) => ResponseContext.jsonBody({'id': r.param('id')}));
await server.post('/echo', (r) => ResponseContext.bytes(r.body));
print('listening on ${server.uri}');
}
Full entrypoint to copy: bin/server.dart (routes, OpenAPI,
metrics, dashboard, drain, hot reload, env config).
- Install
- Build from source
- CLI
- Routes
- Request
- Response
- WebSocket
- Middleware
- Configuration
- Production
- Testing
- Hot reload
- Native library
- Exceptions
- Limits
- Performance
- Developing
Install #
dart pub add nitro_server
- Released versions need no toolchain: the build hook downloads the prebuilt
engine for your OS/arch and
dart build clibundles it. - pub resolves the package only when a Flutter SDK is visible (it is also a
Flutter FFI plugin, and pub demands that for the plugin block): use the
dartthat ships with Flutter, or setFLUTTER_ROOTto a Flutter SDK. The Dockerfile fromnitro deploy dockerhandles this with a stub. - Prebuilts are self-contained (libuv, OpenSSL, brotli, nghttp2 linked in; libstdc++ too on Linux). Minimum hosts: Linux glibc 2.35 (Ubuntu 22.04, Debian 12 or newer), macOS 14, Windows 10 x64. Older hosts build from source.
- Downloads are verified against
prebuilt_checksums.json(ships in the package).NITRO_SERVER_VERIFY_SIGSTORE=1also runscosign verify-blob.NITRO_SERVER_PREBUILT_BASE_URLpoints at a mirror. - Do not
dart compile exea nitro_server program (no build hook, no engine). Usedart build clior the CLI below.
Build from source #
Needed for an unreleased version, an uncovered OS/arch, or
NITRO_SERVER_BUILD_FROM_SOURCE=1. Requires a C++17 compiler, CMake โฅ 3.10,
Dart โฅ 3.12.
| Library | Enables | Required |
|---|---|---|
| libuv | reactor | yes |
| OpenSSL 3 | TLS (and h2 over TLS) | optional, auto-detected |
| libbrotli | compress() br |
optional, auto-detected |
| nghttp2 | HTTP/2 (-DNITRO_SERVER_HTTP2=ON) |
optional |
brew install cmake pkg-config libuv openssl@3 brotli nghttp2 # macOS
sudo apt-get install -y cmake pkg-config libuv1-dev libssl-dev libbrotli-dev libnghttp2-dev # Debian/Ubuntu
vcpkg install libuv openssl brotli nghttp2 --triplet x64-windows # Windows
cmake -S src -B build/lib -DCMAKE_BUILD_TYPE=Release -DNITRO_SERVER_HTTP2=ON
cmake --build build/lib --parallel
CMake options:
| Option | Effect |
|---|---|
-DNITRO_SERVER_HTTP2=ON |
HTTP/2 (needs nghttp2) |
-DNITRO_SERVER_STATIC_DEPS=ON |
link libuv/OpenSSL/brotli/nghttp2 statically |
-DNITRO_SERVER_WERROR=ON |
warnings are errors |
-DNITRO_SERVER_SANITIZE=address|thread|undefined |
instrumented build |
-DOPENSSL_ROOT_DIR=โฆ |
point at an OpenSSL |
Windows: use the vcpkg toolchain file and let CMake pick the generator (or
-G Ninja).
cmake -S src -B build/lib -DCMAKE_BUILD_TYPE=Release -DNITRO_SERVER_HTTP2=ON `
-DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" `
-DVCPKG_TARGET_TRIPLET=x64-windows
cmake --build build/lib --config Release --parallel
Runtime checks: brotliAvailable(); bind throws ServerTlsException when
TLS is requested on an engine without OpenSSL.
CLI #
dart run nitro_server:nitro <cmd> (or nitro <cmd> after
dart pub global activate nitro_server).
| Command | Does |
|---|---|
serve |
run bin/server.dart, JIT + hot reload |
serve <entry> |
another entrypoint |
serve --aot |
compile, then run |
build |
AOT bundle โ build/<name>/bundle/bin/<name> (engine in bundle/lib/) |
build --jit |
kernel snapshot โ build/<name>.dill |
build -o <path> |
output path |
build --bundle-deps |
copy native deps beside the exe |
deploy <target> |
write what a host needs: docker (Dockerfile), cloudflare (Dockerfile + Worker + wrangler.jsonc), netlify/vercel (static site + API proxy config); --run also runs the deploy commands |
dart run nitro_server:nitro serve # dev
dart run nitro_server:nitro build # prod: ./build/server/bundle/bin/server
docker build -t myapp . && docker run -p 8080:8080 -e ADMIN_USER=ops -e ADMIN_PASSWORD=... myapp
Deploy:
dart run nitro_server:nitro deploy docker # Dockerfile (prebuilt engine, no cmake) -> any container host
dart run nitro_server:nitro deploy cloudflare --name my-api # + worker.js, wrangler.jsonc; then: npm install && npx wrangler deploy
dart run nitro_server:nitro deploy netlify --backend https://api.example.com # netlify.toml: static site, /api/* proxied
dart run nitro_server:nitro deploy vercel --backend https://api.example.com # vercel.json: static site, /api/* rewritten
- Only container hosts run the server (Docker, Cloudflare Containers, Fly.io,
Render, Cloud Run, Kubernetes); Netlify and Vercel host a static site and
proxy to a running backend, hence
--backend. - Add
--runto execute the deploy commands;--platform hostbuilds the Docker image for your own machine (defaultlinux/amd64, what hosts run). - Step-by-step guides per host:
doc/deployment.md.
Routes #
Snippets run inside setup(NitroServer server) or after
final server = await NitroServer.bind(const ServerConfig(port: 8080));.
Handlers may be sync or async. await registrations.
await server.get('/users/:id', (r) => ResponseContext.text('user ${r.param('id')}'));
await server.post('/upload/*', (r) => ResponseContext.text('${r.body.length} bytes'),
maxBodyBytes: 50 * 1024 * 1024);
await server.put('/items/:id', (r) => ResponseContext.jsonBody(r.jsonMap()));
await server.delete('/items/:id', (r) => ResponseContext.text('', status: 204));
await server.all('/ping', (_) => ResponseContext.text('pong'));
await server.route(HttpMethod.custom, '/items', (_) => ResponseContext.text('report'),
customMethod: 'REPORT');
await server.get('/slow', (_) => ResponseContext.text('done'),
timeout: const Duration(seconds: 5)); // 408 past it
final api = server.group('/api/v1');
await api.use(cors()); // before the group's routes
await api.get('/ping', (_) => ResponseContext.text('pong'));
final v2 = api.group('/v2');
server.notFoundHandler = (r) => ResponseContext.text('no ${r.path}', status: 404);
server.errorHandler = (e, r) => ResponseContext.text('oops: $e', status: 500);
await server.unroute(HttpMethod.get, '/slow');
Controller class:
class UserController extends Controller {
UserController() : super(prefix: '/users', middleware: [cors()]);
final _users = <String, Object?>{'42': 'Ada'};
@override
Future<void> register(RouteGroup on) async {
await on.get('/', (_) => ResponseContext.jsonBody(_users));
await on.get('/:id', getOne);
await on.post('/', create);
}
ResponseContext getOne(RequestContext c) =>
ResponseContext.jsonBody({c.param('id'): _users[c.param('id')]});
ResponseContext create(RequestContext c) {
final body = c.jsonMap();
_users['${body['id']}'] = body['name'];
return ResponseContext.jsonBody(body, status: 201);
}
}
await server.mount(UserController());
Engine-served static responses (no handler, no middleware, no FFI):
await server.getStatic('/health', utf8.encode('ok'), contentType: 'text/plain');
await server.staticRoute(HttpMethod.get, '/version', utf8.encode('{"v":"1.0.0"}'),
contentType: 'application/json', headers: {'cache-control': 'max-age=60'});
- Precedence: static segment >
:param>*; method-specific >all; HEAD falls back to GET. - Middleware order: server โ group โ route.
- Handler past
timeoutโ 408. Throwing handler โ 500 (orerrorHandler).
Request #
await server.post('/users/:id', (r) {
r.method; // HttpMethod.post (r.customMethod for custom verbs)
r.path; // '/users/42' (r.routePattern = '/users/:id')
r.param('id'); // '42' (r.params)
r.queryParam('page'); // r.queryParameters, r.queryParamAll, r.queryParametersAll
r.header('x-request-id'); // first value, case-insensitive; r.headers: Map<String, List<String>>
r.cookies['session'];
r.clientIp; // '' when unavailable
r.body; // Uint8List
r.text();
final m = r.jsonMap(); // also jsonList(), json(), jsonAs<T>()
return ResponseContext.jsonBody({'id': r.param('id'), 'got': m});
});
Multipart:
await server.post('/upload', (r) {
final parts = r.multipart(); // FormatException unless multipart/form-data
for (final p in parts) {
if (p.isFile) {
File('uploads/${p.filename}').writeAsBytesSync(p.bytes); // p.contentType
} else {
print('${p.name} = ${p.text()}');
}
}
return ResponseContext.jsonBody({'parts': parts.length});
}, maxBodyBytes: 50 * 1024 * 1024);
Limits: maxParts 1000, maxPartHeaderBytes 8 KiB, boundary 1โ70 chars; parts
are views into r.body.
Streaming upload (HTTP/1.1):
await server.post('/ingest', (r) async {
var total = 0;
await for (final chunk in r.bodyStream!) {
total += chunk.length;
}
return ResponseContext.text('received $total bytes');
}, streamBody: true); // r.body is empty, r.bodyStream is set
Response #
All take status: and headers:.
ResponseContext.text('hi'); // text/plain; charset=utf-8
ResponseContext.text('<b>hi</b>', contentType: 'text/html');
ResponseContext.html('<h1>hi</h1>');
ResponseContext.jsonBody({'ok': true, 'n': [1, 2]}); // any JSON-encodable
ResponseContext.jsonMap({'ok': true});
ResponseContext.json('{"pre":"encoded"}');
ResponseContext.bytes(Uint8List(0), contentType: 'image/png');
ResponseContext.redirect('/login'); // 302; status: 301/303/307/308
ResponseContext.file('report.pdf', contentType: 'application/pdf'); // engine-sent in chunks; offset:/length:
ResponseContext.stream(Stream.value(Uint8List(0)), contentType: 'text/csv'); // chunked
ResponseContext.text('created', status: 201, headers: {'location': '/items/7'});
Cookies and headers:
await server.get('/login', (_) => ResponseContext.text('ok')
.withCookie(SetCookie('session', 'abc123',
httpOnly: true, secure: true, sameSite: SameSite.lax,
maxAge: const Duration(days: 7), path: '/'))
.withHeader('cache-control', 'no-store'));
copyWith(status:, headers:) keeps a stream/file/cookies. A header or cookie
with CR/LF/NUL is never sent: 500 + handlerError event.
Server-Sent Events:
await server.get('/events', (_) => ResponseContext.sse(
Stream.periodic(const Duration(seconds: 1),
(i) => SseEvent('tick $i', event: 'tick', id: '$i')).take(10),
));
SseEvent(data, {event, id, retry}), bufferSize: batches writes. Send
something more often than streamIdleTimeout (5 min) or the stream is closed.
Problem details (RFC 7807):
await server.post('/orders', (r) {
if (r.jsonMap()['email'] == null) {
return ResponseContext.problem(
title: 'Validation failed', status: 422, detail: 'email is required',
type: 'https://example.com/errors/validation', extensions: {'field': 'email'},
);
}
return ResponseContext.text('ok', status: 201);
});
Native JSON writer (encodes into the engine's buffer, no Dart string):
final kId = JsonToken('id'), kName = JsonToken('name'); // hoist interned keys
await server.get('/report', (_) => ResponseContext.jsonWriter((w) {
w..beginObject()..key('ok')..writeBool(true)..key('rows')..beginArray();
for (var i = 0; i < 3; i++) {
w..beginObject()..keyToken(kId)..writeInt(i)..keyToken(kName)..writeString('row $i')..endObject();
}
w..endArray()..endObject();
}));
writeInt/Double/Num/String/Bool/Null, writeValue(any), writeRaw(fragment),
keyToken/writeStringToken. The build callback is synchronous.
WebSocket #
await server.ws('/chat', (session) async {
session.protocol; // negotiated subprotocol or null
session.handshake.header('x-user'); // the upgrade request
await for (final m in session.messages) {
if (m is WsText) session.sendText('echo: ${m.text}'); // returns queued bytes; -1 = closed
if (m is WsBinary) session.sendBytes(m.bytes);
if (session.bufferedBytes > 1 << 20) await session.close(1009);
}
// returning or throwing closes the session; session.closeCode = peer's code
}, protocols: ['chat.v1'], middleware: [bearerAuth((t) => t == 'secret')]);
WsSession:handshake,messages(WsText|WsBinary),sendText,sendBytes,bufferedBytes,compressed,protocol,closeCode,close([code = 1000]).- Engine does the handshake, ping/pong, close codes,
permessage-deflate. - Server-global middleware does not run on WS routes. Use
ws(middleware: [...]): a rejection closes with4000 + status(401 โ 4401),1008for a non-4xx,1011if the middleware throws. group.ws(...)prepends the group'suselist.
Middleware #
await server.use(accessLog()); // json: true for JSON lines
await server.use(requestId()); // x-request-id in/out
await server.use(cors(allowOrigin: 'https://example.com', allowCredentials: true));
await server.use(compress(minBytes: 1024, level: 1, brotliQuality: 4));
await server.use(rateLimit(maxRequests: 100, window: const Duration(minutes: 1)));
await server.use(rateLimit(maxRequests: 100, trustedProxies: ['10.0.0.0/8'])); // believe X-Forwarded-For from these peers only
await server.use(bearerAuth((token) => token == 'secret', realm: 'api'));
await server.use(apiKeyAuth((key) => key == 'k', headerName: 'x-api-key'));
await server.use(basicAuth((user, pass) => user == 'admin' && pass == 'pw'));
await server.use(originPolicy(['https://app.example.com'])); // 403 unless Origin matches; also gates ws() handshakes (4403)
// Static files: a handler on a wildcard route
await server.get('/assets/*', staticFiles('web', maxAge: const Duration(hours: 1)));
staticFiles: etag, 304, byte ranges,index.html;.., encoded dots, NUL, symlinks out of the root and dotfiles are 404 (dotfiles: true,followSymlinks: falseto change).- Scope:
server.use(server-wide),group.use(before its routes),middleware: [...]per route orws(), controllers. cors()refuses*with credentials.rateLimitkeys on the socket peer unlesstrustedProxiesnames the peer.
Custom middleware:
Middleware timing() => (request, next) async {
final sw = Stopwatch()..start();
final res = await next(request);
return res.withHeader('x-elapsed-ms', '${sw.elapsedMilliseconds}');
};
final Middleware requireAdmin = (request, next) => request.header('x-role') == 'admin'
? next(request)
: ResponseContext.text('forbidden', status: 403);
await server.use(timing());
await server.get('/admin', (_) => ResponseContext.text('hi'), middleware: [requireAdmin]);
Shared rate-limit store (default store is per isolate, bounded to maxKeys):
class CountingStore implements RateLimitStore { // swap the map for Redis INCR+EXPIRE
final _hits = <String, RateLimitHit>{};
@override
RateLimitHit hit(String key, Duration window) {
final now = DateTime.now();
final prev = _hits[key];
return _hits[key] = (prev == null || now.isAfter(prev.resetAt))
? RateLimitHit(count: 1, resetAt: now.add(window))
: RateLimitHit(count: prev.count + 1, resetAt: prev.resetAt);
}
}
await server.use(rateLimit(
maxRequests: 100,
store: CountingStore(),
keyExtractor: (r) => r.header('x-api-key') ?? r.clientIp,
onLimit: (retryAfter) => ResponseContext.problem(title: 'Too many requests', status: 429),
));
JSON body validation:
final userSchema = ApiSchema.object({
'name': ApiSchema.string(),
'email': ApiSchema.string(format: 'email'),
'age': ApiSchema.integer(nullable: true),
}, required: ['name', 'email']);
await server.post('/users', (r) => ResponseContext.jsonBody(r.jsonMap(), status: 201),
middleware: [validateJsonBody(userSchema)]); // 400 bad JSON, 422 schema failure
ApiSchema.string/integer/number/boolean/object/array/ref, schema.validate(v),
toJson(); maxErrors (100) caps the 422 list.
Configuration #
Future<void> setup(NitroServer server) async {
await server.get('/report', (_) => ResponseContext.jsonBody({'ok': true}));
}
final server = await NitroServer.bind(
const ServerConfig(port: 8080, isolates: 0),
setup, // required when isolates != 1
);
ServerConfig |
default | meaning |
|---|---|---|
host, port, backlog |
127.0.0.1, 0, 128 |
bind address (IPv6 and :: ok), port (0 = free), listen backlog |
isolates |
1 |
handler isolates; 0 = one per core (needs setup); raise for CPU-bound handlers |
workerThreads |
0 |
libuv loop threads; 0 = one per core |
defaultTimeout |
30 s | handler deadline, 408 past it and the request is released; Duration.zero disables |
keepAliveTimeout, maxRequestsPerConnection |
5 s, 100 |
idle deadline between requests; requests per connection (0 = unbounded) |
headerTimeout |
idle timeout | absolute deadline from the first byte of a request head / TLS handshake |
bodyTimeout |
keep-alive timeout | stall window for a request body still arriving: 408 |
writeTimeout |
30 s | stalled write drops the connection (+ clientError); cannot be disabled |
streamIdleTimeout |
5 min | streaming response with no write for this long is closed (+ clientError); Duration.zero disables |
maxConnectionAge |
0 = unbounded |
hard connection lifetime (HTTP/1.1 after its answer, WS 1001, h2 GOAWAY) |
wsIdleTimeout |
60 s | WS pinged at half, closed 1001 at the full window; Duration.zero disables |
maxBodyBytes |
10 MiB | request body cap, 413; per route via maxBodyBytes: |
maxBufferedBodyBytes |
64 MiB | HTTP/2: request-body bytes one session holds across streams; over the remainder 503, never fits 413; โฅ maxBodyBytes |
maxConnections, maxConnectionsPerIp |
0 = unlimited |
refused at accept; per-IP follows the PROXY client when proxyProtocol |
maxInFlightRequests |
0 = unlimited |
dispatched-not-answered budget; 503 + Retry-After: 1 past it |
wsMaxBufferBytes, wsCompression |
1 MiB, true |
WS queue bound each direction (1009 past it); permessage-deflate |
streamMaxBufferBytes |
4 MiB | unsent bytes a streaming response may queue; connection dropped past it; 0 = unlimited |
exposeErrorDetails |
false |
true puts the error text in the 500 body (dev only); details always go to events |
proxyProtocol |
false |
expect a HAProxy PROXY v1/v2 header first; its address becomes clientIp |
responseTiming |
true |
Server-Timing: app;dur=<ms> on handler answers |
serverHeader |
'Nitro Dart Server' |
x-server value on every answer (a handler's own wins); null sends none |
keepAliveHeader |
true |
Keep-Alive: timeout=N next to Connection: keep-alive; false omits the line |
collectMetrics |
true |
per-route counts / status classes / latency for /metrics; false skips that per-request work |
tls |
none | TlsConfig(certPem:/keyPem: or certFile:/keyFile:); minVersion, ciphers, cipherSuites, clientCaPem:/clientCaFile: (mutual TLS) |
protocol |
HttpProtocol.http2 |
http1, http2 (ALPN h2 + h2c, falls back to 1.1), http3 (reserved) |
Memory per connection: one body (h1) or maxBufferedBodyBytes (h2) +
streamMaxBufferBytes / wsMaxBufferBytes + 64 KiB parse buffer. Multiply by
maxConnections for the server bound.
Every default lives in ServerDefaults, so configs can be built relative to
them. A minimal-overhead config (no optional headers, timers or metrics):
final lean = ServerConfig(
maxBodyBytes: ServerDefaults.maxBodyBytes * 4,
responseTiming: false, // no Server-Timing
serverHeader: null, // no x-server
keepAliveHeader: false, // no Keep-Alive: timeout=N
collectMetrics: false, // /metrics reports zero requests
defaultTimeout: Duration.zero,
protocol: HttpProtocol.http1,
);
config.copyWith(...) (clearServerHeader: true drops x-server); bind
runs config.validate() (ArgumentError).
Environment:
final server = await NitroServer.bind(ServerConfig.fromEnv(Platform.environment), setup);
// HOST PORT BACKLOG ISOLATES WORKER_THREADS MAX_BODY_BYTES MAX_BUFFERED_BODY_BYTES MAX_CONNECTIONS
// MAX_CONNECTIONS_PER_IP MAX_REQUESTS_PER_CONNECTION MAX_IN_FLIGHT_REQUESTS MAX_CONNECTION_AGE_MS
// KEEP_ALIVE_TIMEOUT_MS DEFAULT_TIMEOUT_MS HEADER_TIMEOUT_MS WRITE_TIMEOUT_MS BODY_TIMEOUT_MS
// STREAM_IDLE_TIMEOUT_MS STREAM_MAX_BUFFER_BYTES WS_IDLE_TIMEOUT_MS WS_MAX_BUFFER_BYTES
// WS_COMPRESSION RESPONSE_TIMING EXPOSE_ERROR_DETAILS PROXY_PROTOCOL HTTP_PROTOCOL
// TLS_CERT_FILE+TLS_KEY_FILE | TLS_CERT_PEM+TLS_KEY_PEM TLS_MIN_VERSION (1.2|1.3)
// TLS_CIPHERS TLS_CIPHER_SUITES TLS_CLIENT_CA_FILE | TLS_CLIENT_CA_PEM
// A value that does not parse throws FormatException.
TLS:
final server = await NitroServer.bind(
const ServerConfig(port: 8443, tls: TlsConfig(certFile: 'cert.pem', keyFile: 'key.pem')),
setup,
);
server.reloadTls(const TlsConfig(certFile: 'renewed.pem', keyFile: 'renewed.key')); // no downtime
Production #
Observability:
var dbReady = false;
await serveObservability(server,
ready: () => dbReady, // /readyz 503 while false
middleware: [basicAuth((u, p) => u == 'ops' && p == env['OPS_PW'])], // guards /metrics + dashboard
maxDashboardViewers: 16,
dashboardPath: null, // drop the dashboard
prefix: 'myapp');
// GET /healthz 200 while serving
// GET /readyz 503 while draining or when a helper isolate died
// GET /metrics Prometheus text (requests, errors, in-flight, responses_total{class}, connections,
// event_loop_lag_seconds, helper_isolates_alive, per-route latency summary)
// GET /dashboard live HTML
- Aggregates across isolates:
await server.aggregatedMetrics();server.metricsis this isolate. prometheusFormat(m)/metricsJson(m)are renderers.- Put
middleware:on a public listener;/metricsreveals route names.
OpenAPI + Swagger UI:
await serveOpenApi(server, title: 'My API'); // spec from routes, Swagger UI at /docs
final api = OpenApi(server, title: 'My API',
securitySchemes: {'bearer': bearerScheme(bearerFormat: 'JWT')});
await api.get('/search', (r) => ResponseContext.jsonBody({'q': r.queryParam('q')}),
doc: const OpenApiOperation(
summary: 'Search',
params: [OpenApiParam.query('q', required: true), OpenApiParam.header('X-Request-Id')],
responseExample: {'total': 0},
));
await api.post('/users', (r) => ResponseContext.jsonBody(r.jsonMap(), status: 201),
doc: const OpenApiOperation(
requestExample: {'name': 'Ada', 'email': 'ada@example.com'},
successStatus: 201,
extraResponses: {'400': OpenApiResponse('Invalid body')},
));
await api.post('/upload', (r) => ResponseContext.jsonBody({'parts': r.multipart().length}),
doc: const OpenApiOperation(requestBodyDoc: OpenApiRequestBody.multipart(schema: {
'type': 'object', 'properties': {'file': {'type': 'string', 'format': 'binary'}},
})));
await api.get('/me', (_) => ResponseContext.jsonBody({'ok': true}),
middleware: [bearerAuth((t) => t == 'secret')],
doc: const OpenApiOperation(requiresAuth: ['bearer']));
await api.serve(middleware: [requireAdmin]); // /openapi.json + /docs
OpenApiParam.query/.header/.cookie;OpenApiRequestBody.json/.form/.multipart/.text/.binary.- Schemes:
bearerScheme(),apiKeyScheme(),basicScheme(),oauth2Scheme(),openIdConnectScheme(). - Swagger UI pinned (
swaggerUiPinnedVersion5.17.14) with SRI + CSP.
Shutdown:
final shutdown = shutdownOnSignals(server,
drain: const Duration(seconds: 15), onSignal: (s) => print('$s: draining'));
await server.onClose;
// or: await server.close(drain: const Duration(seconds: 15));
Events:
server.events.listen((e) => print('${e.kind.name} #${e.requestId}: ${e.message}'));
// started, stopped, handlerTimeout, clientError, clientDisconnect, notice (helper isolate
// died, ...), handlerError (handler threw or produced an unsendable response; message has
// verb, path, error, stack)
Banner:
print(serverBanner(
title: 'my-api', version: '1.0.0', uri: server.uri,
isolates: server.isolates, pid: pid, routes: server.routes,
links: {'docs': '/docs', 'metrics': '/metrics'},
facts: ['hot reload off'], logo: nitroServerAsciiLogo,
color: stdout.supportsAnsiEscapes,
));
Testing #
package:nitro_server/testing.dart: the real runner in memory, no sockets, no
native library.
import 'package:nitro_server/nitro_server.dart';
import 'package:nitro_server/testing.dart';
import 'package:test/test.dart';
void main() {
test('routes and websocket', () async {
final client = await NitroTestClient.start();
final s = client.server;
await s.get('/users/:id', (r) => ResponseContext.text('u${r.param('id')}'));
await s.post('/echo', (r) => ResponseContext.jsonBody(r.jsonMap()));
await s.ws('/ws', (session) async {
await for (final m in session.messages) {
if (m is WsText) session.sendText('echo: ${m.text}');
}
});
expect((await client.get('/users/42')).text(), 'u42');
final posted = await client.post('/echo',
body: {'a': 1}, headers: {'content-type': 'application/json'});
expect(posted.status, 200);
expect(posted.json(), {'a': 1});
final ws = await client.ws('/ws');
ws.sendText('hi');
expect((await ws.messages.first).text, 'echo: hi');
await ws.close();
await client.close();
});
}
Not enforced in memory: route timeouts, maxBodyBytes; clientIp is '',
port is 0.
Hot reload #
import 'package:nitro_server/hot_reload.dart';
import 'package:nitro_server/nitro_server.dart';
Future<void> setup(NitroServer server) async {
await server.get('/hello', (_) => ResponseContext.text('hi'));
}
Future<void> main() async {
final server = await NitroServer.bind(const ServerConfig(port: 8080), setup);
try {
await enableHotReload(server, log: print); // watch: ['lib', 'bin']
} catch (_) {
// AOT or no VM service
}
await server.onClose;
}
server.reload([setup])swaps the whole routing surface on the live socket.- Needs
hotreloaderindev_dependenciesanddart run --enable-vm-service; throws under AOT. setupmust be a top-level or static function.
Native library #
Search order: explicit path โ NITRO_SERVER_DYLIB โ beside the executable
(lib/, ../lib/) โ .dart_tool/lib โ build/lib โ build/.
loadNitroServerNative(path: '/opt/libs/libnitro_server.so');
final server = await NitroServer.bindWith(port: 8080, dylibPath: '/opt/libs/libnitro_server.so');
Exceptions #
try {
await NitroServer.bind(const ServerConfig(port: 80));
} on ServerBindException catch (e) {
print('cannot bind: ${e.message}');
} on ServerTlsException catch (e) {
print('TLS unavailable: ${e.message}');
}
NitroServerExceptionsubclasses:ServerBindException,ServerTlsException,ServerAlreadyRunningException,ServerNotRunningException,RouteNotFoundException,ServerBadRequestException,HandlerTimeoutException,ServerUnknownException.ArgumentError: bad pattern/prefix,isolates > 1withoutsetup,config.validate(),cors()wildcard + credentials,rateLimitlimits.StateError:reloadwithout a setup, missing dylib, helper isolate that never starts.FormatException:json*(),multipart(),fromEnv.- A throwing handler answers
500 internal server error; details go toevents(handlerError).
Limits #
- Platforms: iOS (foreground), Android (foreground service), macOS, Linux, Windows. No web.
- HTTP/3 not implemented; terminate it at a proxy.
- TLS/brotli are build-time (prebuilts have both).
- Not serverless: a persistent process.
metricsand the default rate-limit store are per isolate (use a sharedRateLimitStore).getStatic/staticRouteanswers never reach Dart: no auth, no logging, no rate limit.- HTTP/2 request bodies are buffered (up to the route's
maxBodyBytes), not streamed;streamBodyis HTTP/1.1 only. - Client addresses come from the socket;
X-Forwarded-Foronly withtrustedProxies; behind an L4 balancer useproxyProtocol. close(drain:): new connections get 503, h2 gets GOAWAY; a connection still in the kernel backlog at final close may be reset.- Helper isolates: one that misses
NitroServer.helperTimeout(10 s) or dies is killed, detached, reported (notice,/readyz503).
Performance #
benchmark/blackbox/run.sh: every framework an opaque HTTP server, oha at
64 keep-alive connections, 3 server cores each, load generator on other cores,
medians of interleaved reps, responses byte-validated. Linux container on
Apple Silicon, nitro 0.8.0, 2026-10-22, final engine, 5 reps. nitro =
_dylib build; @Native within 4% on every route. Hardening cost vs the
pre-audit tree, same run (--base): 1โ4% on light routes (widest
-5.7% on /q?a=1&b=two), none on /work.
| framework | configuration |
|---|---|
| nitro | isolates: 3 |
| go | net/http, GOMAXPROCS=3 |
| node | http + cluster, 3 workers |
| dart:io | HttpServer, 3 isolates (SO_REUSEPORT) |
| shelf | shelf_io.serve, 3 isolates |
Throughput (req/s, median; bold = fastest):
| route | nitro | go | node | dart:io | shelf |
|---|---|---|---|---|---|
/json (light I/O)ยน |
166k | 220k | 150k | 71k | 61k |
/work (encode 200 JSON records)ยฒ |
46k | 17k | 54k | 8.6k | 8.4k |
/echo (4 KB body) |
99k | 80k | 110k | 64k | 46k |
/static (engine fast-path)ยณ |
455k | 223k | 148k | 75k | 61k |
ยน /hello, /q, /users, /files, /mw are within ~5% of /json.
ยฒ per-request encode on every framework.
ยณ nitro answers from the engine (getStatic); the others run a handler.
Tail latency, /work (ms, median; bold = best p99):
| framework | p50 | p90 | p99 | p50:p99 |
|---|---|---|---|---|
| node | 1.05 | 1.52 | 3.59 | 3.4ร |
| nitro | 1.21 | 2.56 | 4.11 | 3.4ร |
| dart:io | 7.27 | 9.18 | 11.36 | 1.6ร |
| shelf | 7.39 | 10.33 | 13.54 | 1.8ร |
| go | 0.93 | 10.89 | 21.18 | 22.7ร |
Peak RSS (whole workload; bold = lowest):
| framework | nitro | go | node | dart:io | shelf |
|---|---|---|---|---|---|
| peak RSS | 151 MB | 12 MB | 391 MB | 53 MB | 51 MB |
nitro = 3 isolate heaps + the engine; a single-isolate server is ~30โ50 MB.
Full report of this run: benchmark/blackbox/REPORT.md. A/B of two nitro
builds: run.sh --base <git-ref> (see benchmark/BENCHMARKING.md).
vs ion_web #
ion_web 0.1.2 + ion_router 0.1.1 (a pure-Dart
HTTP/1.1 server on dart:io sockets), same harness, 2026-10-25, 5 reps,
order alternated per rep. Configurations match: 3 isolates each on the same 3
pinned cores, AOT, keep-alive without a request cap, HTTP/1.1 only, a router +
one middleware layer, synchronous handlers, no per-request timeout, no metrics,
and byte-identical bodies and headers on every route (validated). nitro
runs with responseTiming, serverHeader, keepAliveHeader and
collectMetrics off so it sends exactly ion's headers.
| route | nitro req/s | ion req/s | nitro p99 | ion p99 |
|---|---|---|---|---|
/json |
205k | 108k | 0.93 ms | 6.05 ms |
/users/42 |
206k | 119k | 1.04 ms | 4.37 ms |
/work |
51k | 25k | 3.54 ms | 7.34 ms |
/echo (4 KB body) |
119k | 73k | 1.16 ms | 5.88 ms |
| peak RSS | 49 MB | 78 MB |
Light routes are 1.7โ1.9ร, p50 within 0.1 ms; the gap is the tail. Full
report: benchmark/blackbox/REPORT-ion.md. Rerun:
benchmark/blackbox/run.sh --cpus 0-5 --mem 7g --reps 5 --frameworks "nitro-native ion"
Developing #
cmake -S src -B build/lib -DCMAKE_BUILD_TYPE=Release -DNITRO_SERVER_BUILD_TESTS=ON -DNITRO_SERVER_HTTP2=ON
cmake --build build/lib --parallel
./build/lib/nitro_server_tests/nitro_server_engine_tests # C++ suite
dart test # Dart suites
bash tool/coverage.sh # Dart 100% line gate
bash tool/cpp_coverage.sh 90 # engine line gate
bash tool/fuzz.sh 60 # libFuzzer targets
run-clang-tidy -p build/tidy src/engine/*.cpp # .clang-tidy
- Bridge spec:
lib/src/nitro_server.native.dart; regenerate withdart run build_runner build+nitrogen link. Never edit*.g.*. - In this repository the hook stages
build/lib/libnitro_server.*(your cmake build) ahead of any prebuilt, sodart testalways runs the engine in the tree; removebuild/libto exercise the download path. - CI (
ci.yml): suites, both coverage gates, ASan/TSan (leak detection on Linux), fuzz smoke, clang-tidy, Docker build +/readyz+ Trivy, h2spec (floor 136/146), Autobahn (0 FAILED).soak.yml: nightly 10-minute memory soak.release.yml: suites โ prebuilts with Sigstore provenance, keyless cosign bundle, SPDX SBOM โverifyjob locks and re-verifies them (tool/update_prebuilt_lock.sh,tool/verify_prebuilt_lock.dart); commit the lock beforedart pub publish. - README code is verified: every
dartblock above is copied verbatim intotest/readme/*.dart, compiled bydart analyze/dart test, andtest/readme_snippets_test.dartfails if a README line is missing there.