hivellm/thunder

HiveLLM binary RPC (Thunder) — wire v1 codec, family profiles, multiplexed client

Maintainers

Package info

github.com/hivellm/thunder-php

pkg:composer/hivellm/thunder

Transparency log

Statistics

Installs: 6

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.2 2026-07-19 06:06 UTC

This package is auto-updated.

Last update: 2026-07-19 13:55:04 UTC


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-variant Value, array-encoded Request / Response, PUSH_ID (= u32::MAX), and the length-prefixed MessagePack frame codec with the cap checked before body allocation. Encoding uses rybakit/msgpack driven 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 protocol Config (SPEC-002): demux by id, per-call timeouts, all three handshake styles, lazy reconnect, push routing, typed errors classifying both conventions, and the connection Pool.
  • tests/ — unit tests plus the corpus loader (TST-020), the primary cross-language proof, run by the default phpunit.

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.