Server + client bindings for libnghttp2 in Object Pascal (Delphi + FPC).
HTTP/2 transport primitives — session state, HPACK, streams, callbacks — packaged as a standalone library, plus a framework-agnostic gRPC layer on top: protobuf codec, service registry, dispatcher, and streaming readers and writers. Use it directly to build HTTP/2 or gRPC servers and clients in Delphi, or via the higher-level horse-provider-nghttp2 glue for the Horse web framework.
The gRPC layer takes an INghttp2Stream and nothing else — no web framework is involved on either side, so any host that owns a stream can serve gRPC with it. horse-provider-nghttp2 is one such host, not a prerequisite. samples/grpc-server is a working server that proves it: two RPCs and a plain HTTP/2 route on one port, in about sixty lines, with no framework in the uses clause.
Parallels the ecosystem's proven pattern:
| Transport library | Horse glue |
|---|---|
Delphi-Cross-Socket |
horse-provider-crosssocket |
mORMot2 |
horse-provider-mormot |
Delphi-nghttp2 (this repo) |
horse-provider-nghttp2 |
Everything marked ✓ is released. Where a section below names a version it
is a tag of this repository, which has been on 1.x since the first public
release; the M0–M5 labels are development-sequence milestones, not versions.
Anything marked v2.x in an older draft came from horse-provider-nghttp2's
own numbering before this library was extracted from it — those features all
arrived here in 1.0.0.
| Layer | State |
|---|---|
| Server-side FFI + session runtime | ✓ (M0 — extracted from horse-provider-nghttp2 2026-08-05) |
| TCP accept loop + cross-platform sockets | ✓ |
Client-side FFI (nghttp2_session_client_new, nghttp2_submit_request, …) |
✓ |
TNghttp2Client — synchronous request/response API |
✓ |
Multiplexed client streams — BeginRequest / PumpAll / TakeResponse |
✓ (MULTISTREAM-1 — N concurrent streams on ONE connection) |
| Native HTTP/2 test client (114/114, six suite configurations) | ✓ |
TLS + ALPN — server side (TTlsServerContext, TTlsConnection) |
✓ |
TLS + ALPN — client side (TTlsClientContext, TTlsClientConnection) |
✓ |
OpenSSL 3.x + 1.1.x FFI with auto-detect + SetDllDirectory for local libs |
✓ |
| mTLS (client cert verification) | ✓ |
Password-protected private keys (SSL_CTX_set_default_passwd_cb) |
implemented, untested — callback wired, no fixture uses an encrypted key |
Async dispatch — host answers OnRequest off the connection thread |
✓ |
| Graceful shutdown — drain contract + two-stage GOAWAY (RFC 9113 §6.8) | ✓ |
| Memory-BIO TLS — OpenSSL never touches the socket (event-loop prerequisite) | ✓ (validated 2026-08-16: Windows/Delphi 12, FPC 3.3.1, Linux64) |
Event-loop I/O — epoll (Nghttp2.Engine.Epoll) + IOCP (Nghttp2.Engine.Iocp) |
✓ (both engines' graceful shutdown validated under load 2026-08-22, 3/3 delivery shapes each) |
gRPC layer — protobuf codec, registry (procedural + RegisterService<T>), dispatcher, all four RPC shapes |
✓ (extracted from horse-provider-nghttp2 2026-08-23; the units never depended on Horse, only their names did) |
.proto tooling — parser, ProtogenCheck verdict CLI, protoc differential test (tools/protogen) |
✓ (26 cases, 0 disagreements protoc would call a defect, vs libprotoc 35.1). See doc/protogen.md |
Code generation — .proto → message, interface and service-skeleton units |
✓ protogen emits all four unit kinds; generated code is compiled and run by the test suite, not just diffed. 7,216 of 7,301 real googleapis schemas generate code that compiles — parsed, emitted, and accepted by the compiler (2026-09-09) |
import closure — protogen follows imports and emits one unit per file |
✓ (IMPORT-1, 1.16.0 — path-derived unit names, qualified cross-file references) |
| Reusable session pool for high-concurrency clients | planned |
Async client API (non-blocking SubmitRequest) |
planned — note BeginRequest/PumpAll already covers concurrency within one connection; what remains is not blocking the calling thread at all |
By default OnRequest runs inline on the connection thread: one request at a
time per connection, so a slow handler blocks every other stream the client
has multiplexed there. Set TNghttp2Config.AsyncDispatch and the host
may answer from its own threads instead.
One libnghttp2 rule shapes the entire design (doc/programmers-guide.rst):
nghttp2_session_send / _recv must never be called from inside a callback
or from a second thread — "it will lead to the crash". Only the
nghttp2_submit_* family is safe, and even that must be serialised.
So in async mode nothing but the connection thread touches the native session. A worker stages its response on the stream object and hands it back through a queue; the connection thread drains that queue between recv calls, submits, and pumps the wire. Three consequences worth knowing before modifying any of it:
- Stream state is reference-counted.
on_stream_closecan fire — client RST_STREAM, dead connection — while a worker still holds the stream, so the session's table, the response queue and the worker each hold a reference. BeginAsyncDispatch/EndAsyncDispatchonINghttp2Streamtell the pump that work is outstanding. CallBeginon the connection thread before handing the stream over, and pairEndin the worker'sfinally; an unmatchedBeginparks that connection until the peer gives up.- The pump wakes on worker completion, not just socket input. A client waiting on a reply sends nothing, so a socket-only wait would hold every response for a full poll interval.
PollIntervalMS bounds how long the pump blocks; MaxConnections caps
concurrency at the other end, since this transport is one thread per
connection.
TNghttp2Server separates draining from stopping — one flag used to
mean both, which tore down the pumps the moment a drain began and discarded
replies whose handlers had already finished:
StopAcceptingNewConnections— closes the listener, raises DRAINING. Every connection keeps pumping and sends a GOAWAY notice (last_stream_id = 2^31-1) so the peer stops opening streams.- The caller waits for both
ActiveRequests → 0andAllConnectionsIdle. The request counter alone is not a drain: a worker retires it when its handler returns, which is before the response has been submitted or written. Stop— raises STOPPING. Each pump sends a second GOAWAY naming the last stream it actually processed, flushes it, and only then closes.
The second GOAWAY is what lets a peer distinguish a request that was served
from one it must replay elsewhere. It is queued between the response drain
and the write loop so it ships in the same burst as the final response — a
one-shot client exits the moment its stream ends, so anything later misses it
— and it uses nghttp2_submit_goaway, never
nghttp2_session_terminate_session, which discards frames already submitted
for open streams.
Nghttp2.Tls.pas no longer hands OpenSSL the socket. SSL_set_fd is replaced
by a pair of in-memory BIOs, and the unit performs every socket read and write
itself:
send: SSL_write(plaintext) → BIO_read(FBioOut) → SocketSendAll
recv: SocketRecv → BIO_write(FBioIn) → SSL_read(plaintext)
Nothing about the public surface changed — Read and Write keep the same
signatures and the same bytes / 0 on clean close / <0 on error contract, and
they still block, because the connection pump is still one thread per
connection. The change is where the blocking happens: inside our own
SocketRecv instead of inside OpenSSL.
That distinction is the whole point. With SSL_set_fd an event loop cannot
drive TLS at all — it has no way to know when OpenSSL wants the descriptor,
and no bound on how long a call will park. With memory BIOs, TLS becomes a
pure state machine fed with buffers, which is what an epoll/IOCP loop needs
and the structure Delphi-Cross-Socket uses over its own engines. This shipped
ahead of that loop so it could be validated on its own: the existing TLS and
mTLS suites exercise the rewrite end to end while the threading model they run
against is unchanged.
Validated 2026-08-16 with no new tests: on Windows/Delphi 12, 94/94 over h2c,
TLS and mTLS plus 16/16 gRPC on all three; build-fpc.sh 15/15 stages on
FPC 3.3.1, including mTLS positive and the uncertified-client rejection; clean
dcclinux64 compile. Those totals are the suite as it stood that day — the
point of the record is that a rewrite this deep needed no new tests, so the
numbers are left as they were rather than restated. It has since grown to
114/114 HTTP and 35/35 gRPC; the current matrix lives in the provider's
doc/platform-coverage.md. The 94 checks run in 117 ms h2c → 146 ms TLS → 189 ms
mTLS, so the handshake pump adds no round trips and the mTLS increment is just
the client-certificate flight.
Two consequences worth knowing:
Writeflushes before it returns success. AfterSSL_writethe ciphertext is only inFBioOut. Returning the byte count without draining it would report bytes as sent while they sit in memory.Pendingcounts two buffers, not one — plaintext already decrypted inside SSL, plus undecrypted ciphertext inFBioIn. A pump that waits onselect()first must check it, or it will wait on a socket that has nothing left to give while a whole record sits undecrypted.
Nghttp2.Socket.pas sets SIGPIPE to SIG_IGN at unit initialisation on
Unix and Delphi POSIX. Writing to a socket whose peer has closed is routine
for a server, and at the default action that write terminates the whole
process — every connection, not just the one that lost its peer, with exit
code 141 and no exception to catch.
SIG_IGN was originally required because Nghttp2.Tls.pas used SSL_set_fd:
OpenSSL wrote straight to the descriptor, where a per-call MSG_NOSIGNAL
could never reach. Since the memory-BIO rewrite every write is our own
SocketSendAll, so per-send flags would now be reachable — but SIG_IGN is
kept, because it is one line covering every send site including any added
later, and process-wide termination is too severe a failure mode to guard
call-by-call.
-
Delphi 10.4 Sydney or later (for inline
var,System.Threading). Older versions gated by{$IF CompilerVersion >= 32.0}. -
Free Pascal 3.2.2 or trunk 3.3.1 / Lazarus (matching) —
{$MODE DELPHI}required. The HTTP/2 transport, TLS and streaming build and pass on 3.2.2 (verified 2026-08-22). The protobuf/gRPC codec needs trunk 3.3.1: 3.2.2'sRttiunit declares noTCustomAttributeand its compiler rejects{$RTTI EXPLICIT}, both of which the attribute-driven serializer requires. -
libnghttp2 ≥ 1.59 at runtime — loaded dynamically at startup (no link-time dependency):
Platform Quick install Full guide Windows prebuilt from curl for Windows bundle (no toolchain needed) doc/getting-nghttp2-windows.mdLinux (Debian/Ubuntu) sudo apt install libnghttp2-14doc/getting-nghttp2-linux.mdLinux (Fedora/RHEL) sudo dnf install libnghttp2doc/getting-nghttp2-linux.mdmacOS brew install nghttp2— Building libnghttp2 yourself — needed for a pinned version, debug symbols, the import
.lib, or a target with no package: both guides carry a from-source route. Windows uses MSVC + CMake (Win64 and Win32 recipes); Linux uses the standard autotools build (from source, plus ARM / cross-compile notes). -
Platforms: Windows (Win32/Win64) and Linux (x86_64, ARM64 via SONAME) are gated on every release. macOS is expected to work but is not tested — the loader knows
libnghttp2.dyliband the default thread-per-connection path is portable POSIX, but the two event-loop engines are epoll and IOCP, and no suite has ever run on a Mac. Treat it as unverified rather than supported.
The library loads libnghttp2 by its stable SONAME (libnghttp2.so.14 on Linux, libnghttp2.dylib on macOS, nghttp2.dll on Windows). No binaries bundled — the platform's package manager owns the file.
boss install github.com/freitasjca/Delphi-nghttp2
Or add to boss.json:
"dependencies": {
"github.com/freitasjca/Delphi-nghttp2": "^1.0.0"
}src/
Nghttp2.Native.pas — libnghttp2 FFI bindings (server + client symbols)
Nghttp2.OpenSSL.pas — OpenSSL FFI bindings (auto-detect 3.x / 1.1.x)
Nghttp2.Types.pas — INghttp2Connection + INghttp2Stream interfaces
Nghttp2.Session.pas — nghttp2 session wrapper + per-stream state machine
Nghttp2.Socket.pas — cross-platform raw TCP (Winsock2 / POSIX / FPC Sockets)
Nghttp2.Tls.pas — memory-BIO TLS layer (TTlsServerContext, TTlsClientContext)
Nghttp2.Engine.Epoll.pas — Linux epoll event loop
Nghttp2.Engine.Iocp.pas — Windows IOCP event loop
Nghttp2.Server.pas — accept loop + per-connection session lifecycle
Nghttp2.Client.pas — synchronous HTTP/2 client (TNghttp2Client + TNghttp2Response)
Nghttp2.Protobuf.pas — Protobuf wire-format codec
Nghttp2.Protobuf.Rtti.pas — RTTI-driven Protobuf ↔ Delphi/FPC record mapping
Nghttp2.Grpc.Attributes.pas — [TGrpcService('pkg.Svc')] for the IInvokable API
Nghttp2.Grpc.Registry.pas — service/method registry (procedural + IInvokable)
Nghttp2.Grpc.Dispatcher.pas — application/grpc interception, framing, trailers
Nghttp2.Grpc.StreamWriter.pas — IGrpcStreamWriter (server-streaming, bidi out)
Nghttp2.Grpc.StreamReader.pas — IGrpcStreamReader (client-streaming, bidi in)
tools/
protogen/ — .proto parser, code generator, verdict CLI, and
a differential test against protoc
samples/
PingClient.dpr — minimal HTTP/2 client
grpc-server/ — a gRPC server on this library alone, no framework
rest-and-grpc/ — REST through Horse and gRPC through this library,
one binary, two listeners
tests/ — the gates. run-tests.bat (Windows) and
build-codec-fpc.sh (FPC) drive every .dpr here:
codec, gRPC framing, conformance, and
Nghttp2ServerSmoke, which is the one stage that
starts a real server
doc/ — design docs, upstream notes, migration guides
protogen generates the message, interface, service-skeleton and registration
units from a .proto:
Protogen -i service.proto -o src/ --unit-prefix MyApp.ServiceImports are followed. Since 1.16.0 the generator loads the whole import
closure rather than the one file named on the command line, emitting one unit
per .proto with unit names derived from each file's path and cross-file
references qualified. A schema that imports another no longer has to be
flattened by hand or generated file-by-file.
Two name-resolution rules ride along, and both can rename identifiers that generated successfully before: nested types are flattened into unit scope and disambiguated when two of them share a leaf name, and type names resolve innermost-outward from the enclosing scope as proto requires. Where your schemas already produced compiling code the output is unchanged — the renames land where the old output did not compile or bound the wrong type.
It never overwrites a service implementation you have edited — that one is
written as .new.pas beside the original, and the other three are regenerated
every run. doc/codegen-guide.md walks the whole path
from a .proto to a running service.
Writing the classes by hand is still supported, and
doc/protogen.md documents the RTTI rules for it — worth
reading either way, because they are the rules generated code obeys and they
mostly fail FPC-only and name the wrong cause when they do. The same doc
has the proto3 subset this library expresses and what it refuses.
ProtogenCheck answers that question for one schema without generating
anything:
ProtogenCheck service.proto
# ACCEPT service.proto (3 message(s), 0 enum(s), 1 service(s))What is refused, measured against 7301 real googleapis schemas rather than
guessed: 14 files, and they are worth naming individually because there are
so few. Seven want Group B scalars (fixed64 ×5, sint32, fixed32), six want
google.protobuf.Api or DescriptorProto, and one is an enum declaring both
minimal and MINIMAL — legal proto3, where identifiers are case-sensitive,
and impossible in Pascal, where they are not. That last one cannot be fixed by
renaming: both values sit in the same enum, so any prefix lands on both.
Everything else — map, oneof (with message members), optional, the
Struct family, Any, and the whole import closure — is supported, and a
refusal always names the construct and explains the obstacle. Refusals were 35
before 1.16.0; the 21 that went away were all proto2 extend, which the parser
now skips rather than rejecting outright.
Which layer a number describes matters here more than the number. The
figure above is 7216 / 7301 schemas whose generated Pascal compiles — the
strongest of the three measures, and the only one that says the generator
works. Two weaker ones have each been quoted as if they were it:
- parses — what an earlier "99.5%" meant for a month. It says nothing about
whether Pascal was emitted at all, and behind it sat three emitter gaps worth
27% of the corpus, the largest being message members inside a
oneof. - parses and emits — better, but still no evidence the compiler accepts the result.
corpus-check.sh reports parse-and-emit; compile-check.sh --all reports the
compile figure and splits FPC compiler crashes from genuine emitter defects,
having once overstated the latter by 3.5x. Re-run both after any generator
change and quote the column you actually ran.
uses Nghttp2.Server, Nghttp2.Types;
procedure Handle(const AStream: INghttp2Stream);
begin
AStream.StatusCode := 200;
AStream.Header['content-type'] := 'text/plain';
AStream.Send(TEncoding.UTF8.GetBytes('hello from HTTP/2'));
end;
var Srv: TNghttp2Server;
begin
Srv := TNghttp2Server.Create;
Srv.OnRequest := Handle;
Srv.Start(TNghttp2Config.Default); // loads libnghttp2, binds :9000
ReadLn;
end;OnRequest is a plain procedure(const AStream: INghttp2Stream) — not
of object, and not an anonymous method. That is deliberate: a plain type
accepts a unit-scope procedure, which is the only shape that compiles on FPC
without FUNCTIONREFERENCES. A host that needs state wraps a class method in
such a trampoline; horse-provider-nghttp2 does exactly that.
(Corrected 2026-08-23 — this example previously assigned an anonymous method to that property, which compiles on neither Delphi nor FPC.)
Start loads libnghttp2 itself and raises if it cannot — no explicit
NghttpLoad call is needed. Earlier versions did not, and only the Horse
provider and TNghttp2Client loaded the library: a program built on
TNghttp2Server directly (this example included) left every FFI pointer nil.
The listener still bound and the banner still printed — that is plain socket
code — and the connection thread then died on a nil call with the exception
captured silently by TThread, leaving the client waiting on a socket nobody
would close. No error, no crash, just a hang.
uses Nghttp2.Client;
var C: TNghttp2Client;
var R: TNghttp2Response;
begin
C := TNghttp2Client.Create;
try
C.Connect('127.0.0.1', 9000);
R := C.SubmitRequest('GET', '/', nil, nil);
WriteLn('Status: ', R.Status);
WriteLn(TEncoding.UTF8.GetString(R.Body));
finally
C.Free;
end;
end;SubmitRequest pumps to completion before returning, so calling it in a loop
serialises the requests. To hold N streams open at once — the thing HTTP/2
exists for — submit them all first, then pump:
uses Nghttp2.Client;
var
C: TNghttp2Client;
Ids: array[0..7] of Int32;
R: TNghttp2Response;
I: Integer;
begin
C := TNghttp2Client.Create;
try
C.Connect('127.0.0.1', 9000);
for I := 0 to 7 do
Ids[I] := C.BeginRequest('GET', '/slow/3000', nil, nil); // returns at once
C.PumpAll(20000); // drives the session until every stream closes
for I := 0 to 7 do
begin
R := C.TakeResponse(Ids[I]); // raises only for THIS stream
WriteLn(Ids[I], ' -> ', R.Status);
end;
finally
C.Free;
end;
end;TakeResponse frees the stream's slot as it hands the response back, and raises
only if that stream failed — one broken stream does not discard the others.
SubmitRequest is itself a wrapper over these three calls.
One thread per client. Concurrency here means multiplexed streams on one connection, not a client shared between threads; use separate clients for separate connections.
MIT. See LICENSE.
- nghttp2 — the C library this wraps
- The extraction from
horse-provider-nghttp2(v0.1, 2026-08-05) was done to mirror theDelphi-Cross-Socket/horse-provider-crosssocketpattern