hivellm / thunder
HiveLLM binary RPC (Thunder) — wire v1 codec, family profiles, multiplexed client
Requires
- php: >=8.2
- rybakit/msgpack: ^0.9
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
- symfony/yaml: ^7.0
README
The PHP lane of the Thunder RPC family (hivellm/thunder). Wire bytes are
identical to the Rust, TypeScript, Python, C# and Go lanes — every
implementation pins its default test run to conformance/vectors/*.yaml
(SPEC-005), so one PR changes wire behaviour everywhere or fails CI.
Layout
src/Wire/— the wire layer (SPEC-001): the 8-variantValue, array-encodedRequest/Response,PUSH_ID(=u32::MAX), and the length-prefixed MessagePack frame codec with the cap checked before body allocation. Encoding usesrybakit/msgpackdriven at the low level, which reproduces the reference (rmp-serde) shortest-form integer packing byte for byte.src/Client/— the multiplexed client (SPEC-003) and the protocolConfig(SPEC-002): demux by id, per-call timeouts, all three handshake styles, lazy reconnect, push routing, typed errors classifying both conventions, and the connectionPool.tests/— unit tests plus the corpus loader (TST-020), the primary cross-language proof, run by the defaultphpunit.
Usage
use HiveLLM\Thunder\Client\Client; use HiveLLM\Thunder\Client\ClientConfig; use HiveLLM\Thunder\Client\Config; use HiveLLM\Thunder\Client\Credentials; use HiveLLM\Thunder\Client\ServerException; use HiveLLM\Thunder\Wire\Value; // Start from THE standard and add only your own identity. Everything else — // handshake shape, frame cap, error convention — is the family default. $config = Config::standard()->withScheme('myapp')->withPort(9000); $client = Client::connect('myapp://localhost:9000', $config, (new ClientConfig())->withCredentials(Credentials::token('secret'))); try { echo $client->call('PING')->asStr(); } catch (ServerException $e) { // Branch on the class and the code, never on message text (CLT-052). error_log("server refused: {$e->errorCode}"); }
Pipelining, and the one thing PHP cannot do
Every other lane demultiplexes with a background reader. PHP has no threads and no event loop, so this client demuxes on read: while waiting for one id it consumes whatever arrives, routing pushes to the handler and dropping unknown ids. Responses are matched by id, never by arrival order — the contract CLT-010 actually specifies.
What genuinely differs is that one PHP thread cannot await two calls at once. For real multiple-in-flight, send and collect are separate steps:
$a = $client->send('SLOW'); // written, not awaited $b = $client->send('FAST'); echo $client->collect($b)->asStr(); // may complete first echo $client->collect($a)->asStr();
call() is the one-at-a-time convenience over both.
Pooling
N operations over a pooled connection pay one connect and one handshake,
not N (CLT-080):
$pool = new Pool('myapp://localhost:9000', $config); $pool->with(function (Client $client): void { $client->call('SET', [Value::str('k'), Value::str('v')]); $client->call('GET', [Value::str('k')]); // same connection });
The callback form is not stylistic: the connection returns in a finally, so
an early return or an exception cannot leak it.
Two more things PHP forces this lane to do differently
A Value is built through factories, never inferred. PHP has one string
type for both text and binary, but Thunder does not: Str encodes as
MessagePack str and Bytes as bin, and WIRE-015 forbids smuggling one as
the other. A design that guessed the variant from the PHP value could not tell
Value::str('AB') from Value::bytes('AB') — so the variant is always named.
A Map is a list of pairs, not a PHP array. Thunder map keys may be any
value and order is observable (WIRE-002); a PHP array would restrict keys to
int|string and collapse duplicates. Use Value::mapOf([...]) for the common
string-keyed case, or MapEntry directly when keys are not strings.
Test / quality gate
composer install vendor/bin/phpstan analyse # type-check first: the faster signal vendor/bin/phpunit # unit tests + the corpus, one command
The corpus is not a separate target. TST-020 requires it in the default test command — never feature-gated, never skipped — because a conformance run that has to be remembered is one that stops happening.
Release train (PKG-011)
This package versions in lockstep with every other lane: one tag, one version,
everywhere. It publishes to Packagist as
hivellm/thunder, which resolves from a VCS tag — so, like the Go lane, there
is no push step, but the tag must exist.
The shared gate in release.yml runs this lane's checks on the tagged commit
before anything ships.
Where this code lives
The source of truth is the php/ directory of the
Thunder monorepo, alongside the other
five lanes and the conformance corpus they all share.
The corpus is read from ../conformance/vectors/ relative to this package. If
this code is ever mirrored to a standalone repository (as the Go lane is), those
vectors are absent there and the corpus tests skip — they still run for real
upstream, which is where a wire change actually lands. A skipped corpus in a
mirror is expected; a skipped corpus in the monorepo is a bug.