Skip to main content

Relay and persistence

Softchat owns relay frame validation and a serialized state machine. Your application owns the WebSocket, durable publish intent, transaction, timer, reachability, and retry execution.

Frames, filters, authentication, and batches

The wire API covers EVENT, REQ, CLOSE, OK, EOSE, CLOSED, NOTICE, AUTH, and COUNT, plus Softchat EVENTS batches. Filters retain exact prefix, time, limit, and generic tag semantics.

An EVENTS batch is flat, non-empty, bounded, and has unique event IDs. The relay returns one terminal OK per event, allowing partial acceptance. Create NIP-42 authentication with the exact relay URL and challenge; do not reuse it for another relay or time window.

let filter_json = format!(
r#"{{"kinds":[14],"#p":["{}"],"limit":100}}"#,
recipient.to_hex(),
);
let filter = RelayFilter::from_json(&filter_json)?;
let request = ClientRelayFrame::Req {
subscription_id: "chat".to_owned(),
filters: vec![filter],
}.to_json()?;
let checked_request = parse_client_relay_frame(&request)?;
let checked_response = parse_relay_response_frame(response_json)?;

let auth = plan_nip42_authentication(
&identity,
challenge,
"wss://relay.example",
created_at,
)?;

Native Rust exposes the direct frame/filter enums. ClientRelayFrame::Events encodes a bounded multi-event batch.

RelaySession lifecycle

The host serializes every call and executes returned actions in order:

connect -> open transport -> transportConnected -> authenticate if requested
-> authenticated -> send queued subscriptions and publishes
receive event -> persistEvent -> host transaction commits -> confirmIngested
transportLost -> persist delivery/retry state -> schedule reconnect
cancel -> close transport -> release session
let mut session = RelaySession::new();
let open = session.connect()?;
socket.open(open);
session.transport_connected()?;

for action in session.receive(frame_json)? {
if action.kind == RelaySessionActionKind::PersistEvent {
let event = action.event.expect("persist actions contain an event");
let event_id = event.id.clone();
database.transaction(|tx| tx.ingest(event))?;
session.confirm_ingested(&event_id)?;
}
}

Publishing and delivery

  1. Commit the event and publish intent locally.
  2. Call publish with a bounded batch.
  3. Send frames emitted by the session.
  4. Feed complete relay responses to receive.
  5. Persist every DELIVERY_CHANGED state.

pending, inFlight, accepted, rejected, and retryable describe host delivery state. A successful socket write is not acceptance. Relay prose is diagnostic context and must not be parsed as a control protocol.

Ingestion adapter contract

IngestionBatch contains verified events. Apply it in one authoritative transaction and return a total, disjoint IngestionResult: every event ID is accepted, already present, or rejected exactly once. The validator rejects an incomplete or overlapping result before session state advances.

Recovery

After transport loss, persist returned delivery changes and the redacted session snapshot, close the native socket, and schedule only the requested delay. Recreate ephemeral platform resources after process death; restore subscriptions and publish intent from the application database.