AMPHP-based async WebSocket relay server for Nostr protocol
A private, high-performance Nostr relay implementation designed to be embedded in PHP applications. Built with AMPHP for concurrent connection handling and clean architecture principles.
Amp\Http\Server\RequestHandler you mount on your own HttpServer, so the host controls binding, middleware (CORS, forwarded headers, compression) and lifecycle, and serves its own routes on the same originNip11InfoProviderInterface: the built-in StaticNip11InfoProvider for a fixed document, or a custom implementation to compute it at runtimeRelayInstance::getMetrics() returns a RelayMetrics snapshot (active connections, events received/sent, subscriptions, start time) via the MetricsCollectorInterface portisRateLimitExempt()) bypassinnis/nostr-core - Core Nostr protocol entitiesamphp/amp ^3.0 - Async runtimeamphp/http-server ^3.0 - HTTP serveramphp/websocket-server ^4.0 - WebSocket serverpsr/log ^3.0 - Logging interfacecomposer require innis/nostr-relay
The relay requires these interfaces from your host application:
RelayEventStoreInterface - Event persistence and queries. Use the built-in InMemoryEventStore to run a relay locally or to give a test a real store; it keeps everything in process memory and matches linearly, so a deployment supplies a durable implementation.RelayConfigInterface - The relay's own configuration: the relay URL (for NIP-42 AUTH verification) and the maximum concurrent connections. The listening address and trusted proxies are configured on the HttpServer the host owns, not here.RateLimitPolicyInterface - Per-minute rate-limit budgets keyed by RateLimitMetric (events, subscriptions). Use the built-in StaticRateLimitPolicy for fixed limits, or implement the interface to vary limits at runtime.Nip11InfoProviderInterface - The single source of the relay's NIP-11 document. Wrap a fixed document in the built-in StaticNip11InfoProvider, or implement the interface to project metadata at runtime (e.g. reflecting live policy).Access control can use the built-in RelayPolicy or a custom implementation of RelayPolicyInterface. See Implementing RelayPolicyInterface for how a policy signals a rejection.
Optional interfaces extend the relay's behaviour:
ConnectionGateInterface - Decide whether an IP may connect, before the WebSocket session is established. Defaults to allowing every IP; implement it to enforce an allow-list or deny-list.MetricsCollectorInterface - Collect relay metrics (connections, events, subscriptions). Defaults to the in-memory InMemoryMetricsCollector exposed via RelayInstance::getMetrics(); implement it to export to an external monitoring system.use Innis\Nostr\Core\Domain\ValueObject\Protocol\Nip11Info;
use Innis\Nostr\Core\Infrastructure\Crypto\NativeRandomBytesGenerator;
use Innis\Nostr\Relay\Application\Service\InMemoryAuthenticationRegistry;
use Innis\Nostr\Relay\Application\Service\RelayPolicy;
use Innis\Nostr\Relay\Domain\ValueObject\RateLimitConfig;
use Innis\Nostr\Relay\Domain\ValueObject\RelayPolicyConfig;
use Innis\Nostr\Relay\Infrastructure\EventStore\InMemoryEventStore;
use Innis\Nostr\Relay\Infrastructure\Http\StaticNip11InfoProvider;
use Innis\Nostr\Relay\Infrastructure\RateLimiting\StaticRateLimitPolicy;
use Innis\Nostr\Relay\Infrastructure\Server\RelayServerFactory;
use function Amp\trapSignal;
$authenticationRegistry = new InMemoryAuthenticationRegistry(new NativeRandomBytesGenerator());
$logger = new \Psr\Log\NullLogger();
$policyConfig = RelayPolicyConfig::tryFromArray([
'tenants' => ['your-hex-pubkey'],
'guest' => [
'read' => [
['kinds' => [0, 1, 6, 7, 30023], 'from' => 'tenants'],
],
'write' => [
['kinds' => [7, 9735]],
],
],
]) ?? throw new RuntimeException('Invalid relay policy configuration');
$policy = new RelayPolicy($authenticationRegistry, $logger, $policyConfig);
$rateLimitPolicy = new StaticRateLimitPolicy(new RateLimitConfig(
eventsPerMinute: 60,
subscriptionsPerMinute: 20,
));
$config = new MyRelayConfig();
$nip11InfoProvider = new StaticNip11InfoProvider(Nip11Info::fromArray($config->getRelayUrl(), [
'name' => 'My Nostr Relay',
'pubkey' => 'your-hex-pubkey',
'supported_nips' => [1, 9, 11, 42, 45],
]));
$factory = new RelayServerFactory(
eventStore: new InMemoryEventStore(), // swap for a durable store in a deployment
policy: $policy,
config: $config,
rateLimitPolicy: $rateLimitPolicy,
authenticationRegistry: $authenticationRegistry,
logger: $logger,
nip11InfoProvider: $nip11InfoProvider,
);
The host owns the HttpServer, so it decides the listening address, middleware and
lifecycle, and mounts the relay's request handler on it. Owning the server is what lets
the host serve its own routes — a landing page, a management API, static files — on the
same origin as the relay:
use Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\SocketHttpServer;
use Amp\Socket\InternetAddress;
use function Amp\trapSignal;
$httpServer = SocketHttpServer::createForDirectAccess($logger);
$httpServer->expose(new InternetAddress('127.0.0.1', 8080));
$relay = $factory->create($httpServer);
$httpServer->start($relay->getRequestHandler(), new DefaultErrorHandler());
trapSignal([SIGINT, SIGTERM]); // start() is non-blocking; keep the event loop alive until interrupted
$httpServer->stop();
See examples/relay.example.php for a complete runnable relay: tenant/guest policy, rate limiting, NIP-11 metadata and a stderr logger, on the built-in InMemoryEventStore.
The relay does not handle TLS. Use a reverse proxy for SSL. If the proxy sets X-Forwarded-For, configure the trusted proxies on the HttpServer the host owns — amphp's ForwardedMiddleware honours the header only for a matching proxy address. The relay reads the client IP from the request the server hands it, so honouring forwarded headers from an untrusted source lets any client spoof their IP; only trust a proxy you control.
upstream nostr_relay {
server 127.0.0.1:8080;
}
server {
listen 443 ssl;
server_name relay.example.com;
location / {
proxy_pass http://nostr_relay;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
The built-in RelayPolicy accepts a configuration array that controls access for tenants and guests.
tenants: array of hex pubkeys or npub strings identifying relay owners. Tenants authenticate via NIP-42 and bypass all guest restrictions. If the array is empty or omitted, the relay operates as an open relay (all writes and reads allowed).
Optional keys with sensible defaults:
max_subscriptions - Maximum concurrent subscriptions per client. Also gates COUNT requests: a COUNT from a client already at the cap is rejected with blocked: too many subscriptions (see ADR-0006).max_filters - Maximum filters per subscriptionmax_event_size - Maximum event payload size in bytesmax_query_limit - Maximum limit value in REQ filtersRelayPolicyInterfaceA policy answers "may this client do this?" by returning an outcome — it never throws to reject. allowEventSubmission() and allowSubscription() both return ?PolicyRejection:
use Innis\Nostr\Relay\Domain\ValueObject\PolicyRejection;
public function allowEventSubmission(RelayClient $client, Event $event): ?PolicyRejection
{
if ($this->isSpam($event)) {
return PolicyRejection::blocked('event rejected by content filter');
}
if (!$this->isAuthenticated($client)) {
return PolicyRejection::authRequired('authentication required to publish this event kind');
}
return null;
}
null to admit.PolicyRejection::blocked($reason) for a definitive refusal.PolicyRejection::authRequired($reason) when authenticating could change the answer. The relay additionally sends the client an AUTH challenge alongside the refusal, so it can authenticate and retry.The relay frames the rejection as the wire reply that matches the client's message: OK for an EVENT, CLOSED for a REQ or COUNT. You do not construct wire messages yourself.
Rejections are returned rather than thrown so the analyser forces every caller to handle them — see ADR-0003. Genuine faults are still exceptions: a structurally invalid or badly-signed event raises InvalidEventException from the core validator, which the relay catches and reports as invalid:.
offersAuthChallenge() is separate from rejection: it lets a policy admit an event and still invite the client to authenticate (see ADR-0011). The built-in policy returns false.
RelayPolicyInterface::isRateLimitExempt() lets the policy opt specific clients out of rate limits and subscription caps. The built-in RelayPolicy exempts authenticated tenants (and everyone on an open relay). Implement RelayPolicyInterface directly to exempt other trusted clients — for example, internal services or IPs behind a trusted proxy.
Unauthenticated clients are treated as guests. Guest permissions are defined under the guest key:
guest.read: array of rules controlling what events guests can query. Each rule has:
kinds (int array) - Event kinds the guest may readfrom (optional, 'tenants') - Restrict results to events authored by tenantsguest.write: array of rules controlling what events guests can publish. Each rule has:
kinds (int array) - Event kinds the guest may publishtagged_to_tenant (optional, true) - Require the event to tag a tenant pubkeyIf no config is passed, the relay is fully open with no restrictions.
The relay does not challenge on connect. It issues an AUTH challenge only when a subscription requests something outside the guest's scope — when the requested kinds aren't guest-readable, when the requested authors aren't tenants (under from = 'tenants'), or when the filter reads a tenant's mailbox (a #p tag referencing a tenant). The challenge is an offer: a client that authenticates gains full scope, while a client that ignores it still receives the guest-scoped results. The connection is never blocked for not authenticating. (Why a scope-exceeding request rather than every connection: see ADR-0004.)
When a client authenticates, its already-open subscriptions are re-evaluated against its new scope: each is re-admitted with its original filters and the now-visible stored events are streamed, so a subscription opened as a guest widens automatically without the client having to re-subscribe (see ADR-0007).
┌─────────────────────────────────────────────────────┐
│ Host Application │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ MyEventStore │ │ MyPolicy │ │ MyConfig │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└───────┬──────────────────┬───────────────┬──────────┘
│ │ │
┌───────▼──────────────────▼───────────────▼──────────┐
│ innis/nostr-relay │
│ │
│ WebSocket Server → Message Router → Use Cases │
│ │
│ SubscriptionRegistry → EventDistributor │
└─────────────────────────────────────────────────────┘
Relay Handles:
Host Application Handles:
RelayPolicy or implement RelayPolicyInterface directly)RelayConfigInterface)Nip11InfoProviderInterface — built-in StaticNip11InfoProvider, or implement for runtime-computed metadata)composer test
Runs the Unit, Integration and Acceptance suites, then the soak harness (tools/soak-harness.php), then
PHPStan level 9 static analysis. composer test-unit runs the Unit suite alone; composer soak the
harness alone.
Manual testing with websocat:
websocat ws://localhost:8080
["REQ","test",{"kinds":[1],"limit":10}]
The relay is designed for concurrent connection handling. Concrete throughput, latency, and memory figures depend on the host's event store and hardware and are not benchmarked here.
Design choices that bear on performance:
FilterMIT License. See LICENSE file for details.
How can I help you explore Laravel packages today?