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.
- Rust
- Kotlin
- Android
- Swift
- TypeScript
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.
session.subscribe(
id = "chat",
filtersJson = listOf(
"""{"kinds":[14],"#p":["${recipient.hex}"],"limit":100}""",
),
)
session.drainOutbound().forEach(socket::sendText)
val auth = identity.createNip42Authentication(
challenge,
"wss://relay.example",
createdAt,
)
publish(listOf(first, second)) selects EVENTS for a multi-event batch.
The session validates filter/frame JSON in Rust; generated codec declarations
remain internal.
Use the Kotlin session calls on the serialized connection coroutine. Feed
complete text frames from the Android WebSocket and send each value returned by
drainOutbound; never parse or assemble relay arrays in UI code.
try session.subscribe(
id: "chat",
filtersJSON: [
"{\"kinds\":[14],\"#p\":[\"\(recipient.hex)\"],\"limit\":100}"
]
)
for frame in try session.drainOutbound() {
try await socket.send(.string(frame))
}
let auth = try identity.createNip42Authentication(
challenge: challenge,
relayURL: "wss://relay.example",
createdAt: createdAt
)
publish([first, second]) selects the bounded EVENTS batch.
session.subscribe("chat", [
`{"kinds":[14],"#p":["${recipient}"],"limit":100}`,
]);
for (const frame of session.drainOutbound()) socket.send(frame);
const auth = identity.createNip42Authentication(
challenge,
"wss://relay.example",
createdAt,
);
session.publish([first, second]) selects the bounded EVENTS 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
- Rust
- Kotlin
- Android
- Swift
- TypeScript
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)?;
}
}
RelaySession().use { session ->
execute(session.connect())
session.transportConnected()
session.receive(frame).forEach { action ->
if (action.kind == RelaySessionAction.Kind.PERSIST_EVENT) {
room.withTransaction { ingest(requireNotNull(action.event)) }
session.confirmIngested(requireNotNull(action.event).id)
}
execute(action)
}
}
Own one session from a serialized coroutine/actor. Persist outbound intent
before publish, feed only complete WebSocket text frames to receive, and
call confirmIngested after the Room transaction commits. Use WorkManager or
connectivity policy to execute SCHEDULE_RECONNECT.
let session = RelaySession()
execute(try session.connect())
try session.transportConnected()
for action in try session.receive(frame) {
if case .persistEvent = action.kind, let event = action.event {
try database.write { try $0.ingest(event) }
try session.confirmIngested(event.id)
}
execute(action)
}
Serialize this code in the same actor that owns the WebSocket and timers.
const session = new RelaySession();
execute(session.connect());
session.transportConnected();
for (const action of session.receive(frame)) {
if (action.kind === "persistEvent" && action.event) {
await database.transaction(tx => tx.ingest(action.event));
session.confirmIngested(action.event.id);
}
execute(action);
}
Run the synchronous state-machine calls in one Worker or serialized task.
Call cancel() before close() while a transport is active.
Publishing and delivery
- Commit the event and publish intent locally.
- Call
publishwith a bounded batch. - Send frames emitted by the session.
- Feed complete relay responses to
receive. - Persist every
DELIVERY_CHANGEDstate.
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.