WebRTCSignaling.kt
src/jvmMain/kotlin/afield/media/webrtc/WebRTCSignaling.kt · 536 lines
package afield.media.webrtc
import afield.app.diagnostics.logging.lprintln
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.WebSocket
import java.security.SecureRandom
import java.time.Duration
import java.util.Base64
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
WebRTC signalling for Field's sources and sinks: the networks a page can join, the relay on Field's own server, and the bridge to an aaaframe classroom.
Pages never connect to a signalling server themselves. They open
/ws/webrtc-signaling?network=<id> on Field, and Field either relays the messages itself
(WebRTCNetwork.local) or copies them to and from aaaframe's /ws/shadertrinket-signaling
(WebRTCNetwork.aaaframe), adding the student's session cookie. The messages are aaaframe's in
both cases, so the page code is the same. The design is in dev_docs/WEBRTC_SOURCES_SINKS.md.
class WebRTCNetwork private constructor
A signalling network: Field's own relay, or an aaaframe server's ShaderTrinket network.
id is what pages pass as network=; only registered networks are reachable, so a page cannot
make Field open a socket anywhere else.
class WebRTCNetwork private constructor(
val id: String,
/** The aaaframe base URL (`https://host` or `http://host:port`), or null for the local relay. */
val baseUrl: String?,
session: String?,
) {
val isLocal: Boolean get() = baseUrl == null
internal var session: String?
The value of aaaframe's session cookie; null for the local relay or before signing in.
@Volatile
internal var session: String? = session
set(value) {
field = value
WebRTCConfig.forget(this)
}
val hasSession: Boolean get() = !session.isNullOrBlank()
True when there is a session to present: from aaaframe's argument, the environment, or a sign-in.
internal fun cookieHeader(): String?
internal fun cookieHeader(): String? = session?.takeIf { it.isNotBlank() }?.let { "session=$it" }
internal fun upstreamSignalingUri(): URI
internal fun upstreamSignalingUri(): URI {
val base = URI(baseUrl!!.trimEnd('/'))
val scheme = if (base.scheme == "https") "wss" else "ws"
return URI(scheme, base.userInfo, base.host, base.port, "${base.path.orEmpty()}/ws/shadertrinket-signaling", null, null)
}
internal fun signedIn(newSession: String, login: String)
Takes a sign-in from Field's aaaframe window (electron/aaaframe-sign-in.js) and remembers it for this user.
internal fun signedIn(newSession: String, login: String) {
AaaframeSignIns.save(baseUrl ?: return, newSession, login)
session = newSession
}
override fun toString(): String
override fun toString(): String = if (isLocal) "WebRTCNetwork(local)" else "WebRTCNetwork($baseUrl)"
companion object
companion object {
private val registry = ConcurrentHashMap<String, WebRTCNetwork>()
val local: WebRTCNetwork = WebRTCNetwork("local", null, null).also { registry[it.id] = it }
The relay on this Field's server, for Field-to-Field on a LAN.
fun aaaframe(baseUrl: String, session: String? = null): WebRTCNetwork
aaaframe's classroom network at baseUrl.
The login is, in order: session if you pass one, AFIELD_AAAFRAME_SESSION, and then the
sign-in Field remembers for this server. With none, the publisher list and the sink's status
line open aaaframe's own sign-in page in a Field window (the Electron app only), and Field
keeps the result in ~/.afield/webrtc/aaaframe-sign-ins.json. A server in local-auth mode
(AAAFRAME_LOCAL_AUTH=1) needs no login at all.
fun aaaframe(baseUrl: String, session: String? = null): WebRTCNetwork {
val trimmed = baseUrl.trim().trimEnd('/')
require(trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
"aaaframe's base URL starts with http:// or https://, not '$baseUrl'"
}
val chosen = session ?: System.getenv("AFIELD_AAAFRAME_SESSION")?.takeIf { it.isNotBlank() }
// One object per server, so a sign-in reaches every box and socket that uses it.
val network = registry.computeIfAbsent(trimmed) {
WebRTCNetwork(trimmed, trimmed, chosen ?: AaaframeSignIns.session(trimmed))
}
if (chosen != null && chosen != network.session) network.session = chosen
return network
}
fun join(baseUrl: String): WebRTCNetwork
Joins the class at baseUrl: makes it the default for every box, now and in later runs of
Field (remembered in ~/.afield/webrtc/aaaframe-sign-ins.json), and returns it. The first
publisher list or sink to use it asks you to sign in.
fun join(baseUrl: String): WebRTCNetwork {
val network = aaaframe(baseUrl)
AaaframeSignIns.classUrl = network.baseUrl
default = network
return network
}
var default: WebRTCNetwork
The network boxes use when they name none: AFIELD_WEBRTC_SERVER (an aaaframe base URL,
or local), else the class you last joined, else local. Assign it to change the default
for this run of Field only.
@Volatile
var default: WebRTCNetwork = (System.getenv("AFIELD_WEBRTC_SERVER")?.trim()?.takeIf { it.isNotEmpty() }
?: AaaframeSignIns.classUrl)?.takeIf { it != "local" }?.let { aaaframe(it) } ?: local
internal fun lookup(id: String?): WebRTCNetwork?
internal fun lookup(id: String?): WebRTCNetwork? = if (id.isNullOrBlank()) default else registry[id]
}
}
internal object AaaframeSignIns
The aaaframe logins Field remembers for this user, and the class it last joined:
~/.afield/webrtc/aaaframe-sign-ins.json, readable only by the user (directory 0700, file 0600).
Never per document and never in a checkout. afield.webrtc.signIns points tests elsewhere.
internal object AaaframeSignIns {
private val file: java.io.File get() = java.io.File(
System.getProperty("afield.webrtc.signIns")
?: "${System.getProperty("user.home")}/.afield/webrtc/aaaframe-sign-ins.json"
)
private fun read(): JsonObject = runCatching { Json.parseToJsonElement(file.readText()).jsonObject }.getOrDefault(JsonObject(emptyMap()))
@Synchronized private fun write(content: JsonObject) {
val target = file
val dir = target.parentFile.toPath()
java.nio.file.Files.createDirectories(dir)
runCatching { java.nio.file.Files.setPosixFilePermissions(dir, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------")) }
val temp = java.nio.file.Files.createTempFile(dir, "sign-ins", ".json")
runCatching { java.nio.file.Files.setPosixFilePermissions(temp, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) }
java.nio.file.Files.writeString(temp, content.toString())
java.nio.file.Files.move(temp, target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
}
private fun servers(content: JsonObject): JsonObject = content["servers"] as? JsonObject ?: JsonObject(emptyMap())
fun session(baseUrl: String): String?
fun session(baseUrl: String): String? = (servers(read())[baseUrl] as? JsonObject)?.string("session")?.ifEmpty { null }
fun login(baseUrl: String): String?
fun login(baseUrl: String): String? = (servers(read())[baseUrl] as? JsonObject)?.string("login")?.ifEmpty { null }
@Synchronized fun save(baseUrl: String, session: String, login: String)
@Synchronized fun save(baseUrl: String, session: String, login: String) {
val content = read()
val entry = buildJsonObject {
put("session", session)
put("login", login)
put("savedAt", java.time.Instant.now().toString())
}
write(JsonObject(content + ("servers" to JsonObject(servers(content) + (baseUrl to entry)))))
}
var classUrl: String?
var classUrl: String?
get() = (read()["class"] as? JsonPrimitive)?.contentOrNull?.ifEmpty { null }
@Synchronized set(value) {
val content = read()
write(JsonObject(if (value == null) content - "class" else content + ("class" to JsonPrimitive(value))))
}
}
internal val defaultIceServers: JsonArray
The STUN servers aaaframe advertises; the local relay offers the same. Offline LANs still connect on host candidates.
internal val defaultIceServers: JsonArray = buildJsonArray {
add(buildJsonObject { put("urls", "stun:stun.l.google.com:19302") })
add(buildJsonObject { put("urls", "stun:stun.cloudflare.com:3478") })
}
internal interface SignalingPeer
One signalling connection, from the relay's side: where its messages go.
fun deliver(text: String)
internal class LocalSignalingRelay(private val login: () -> String
Field's own signalling relay: aaaframe's handle_shader_signal_message, without WHIP.
A connection says hello, may publish under login/suffix, asks a publisher to watch, and
relays offer/answer/candidate/hangup to a toConnectionId, which the relay stamps with
from and fromConnectionId. Every connection hears publisher-online/publisher-offline.
internal class LocalSignalingRelay(private val login: () -> String = ::localWebRTCLogin) {
private inner class Connection(val id: String, val login: String, val peer: SignalingPeer) {
@Volatile var publishName: String? = null
}
private val connections = ConcurrentHashMap<String, Connection>()
private val publishers = ConcurrentHashMap<String, String>()
private val listeners = CopyOnWriteArrayList<(List<String>) -> Unit>()
private val random = SecureRandom()
fun publishers(): List<String> = publishers.keys.sorted()
fun onPublishersChanged(listener: (List<String>) -> Unit): () -> Unit {
Called with the sorted publisher names whenever they change. Returns the remover.
fun onPublishersChanged(listener: (List<String>) -> Unit): () -> Unit {
listeners += listener
return { listeners -= listener }
}
fun connect(peer: SignalingPeer): String
Opens a connection; it is greeted at once. Returns its connection id.
fun connect(peer: SignalingPeer): String {
val id = UUID.randomUUID().toString().replace("-", "")
val connection = Connection(id, normalizeLogin(login()), peer)
connections[id] = connection
send(connection, buildJsonObject {
put("type", "hello")
put("username", connection.login)
put("connectionId", id)
put("publishers", JsonArray(publishers().map(::JsonPrimitive)))
})
return id
}
fun disconnect(id: String)
@Synchronized
fun disconnect(id: String) {
val connection = connections[id] ?: return
clearPublish(connection)
connections.remove(id)
}
fun receive(id: String, text: String)
body — 70 lines, from line 240
@Synchronized
fun receive(id: String, text: String) {
val connection = connections[id] ?: return
val message = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return
when (val type = message.string("type")) {
"publish-start" -> {
val name = publishName(connection.login, message.string("suffix"))
clearPublish(connection)
val previous = publishers[name]
if (previous != null && previous != id) connections[previous]?.let(::clearPublish)
publishers[name] = id
connection.publishName = name
broadcast(buildJsonObject { put("type", "publisher-online"); put("username", name) })
send(connection, buildJsonObject {
put("type", "publish-ready")
put("username", name)
put("replaced", previous != null && previous != id)
})
}
"publish-stop" -> clearPublish(connection)
"watch" -> {
val target = normalizePublishName(message.string("target"))
val requestId = message.string("requestId").ifEmpty { token() }
val publisher = publishers[target]?.let(connections::get)
if (publisher == null) {
send(connection, buildJsonObject {
put("type", "publisher-unavailable"); put("username", target); put("requestId", requestId)
})
} else {
send(publisher, buildJsonObject {
put("type", "watch")
put("from", connection.login)
put("fromConnectionId", id)
put("requestId", requestId)
put("target", target)
})
}
}
"offer", "answer", "candidate", "hangup" -> {
val to = connections[message.string("toConnectionId")] ?: return
send(to, JsonObject(message + mapOf(
"from" to JsonPrimitive(connection.publishName ?: connection.login),
"fromConnectionId" to JsonPrimitive(id),
)))
}
else -> lprintln("debug,webrtc", "local relay ignored a '$type' message")
}
}
private fun clearPublish(connection: Connection) {
val name = connection.publishName ?: return
connection.publishName = null
if (publishers[name] == connection.id) {
publishers.remove(name)
broadcast(buildJsonObject { put("type", "publisher-offline"); put("username", name) })
}
}
private fun broadcast(message: JsonObject) {
connections.values.forEach { send(it, message) }
val names = publishers()
listeners.forEach { runCatching { it(names) } }
}
private fun send(connection: Connection, message: JsonObject) {
runCatching { connection.peer.deliver(message.toString()) }
.onFailure { lprintln("debug,webrtc", "local relay could not deliver to ${connection.id}: ${it.message}") }
}
private fun token(): String = ByteArray(6).also(random::nextBytes).let { Base64.getUrlEncoder().withoutPadding().encodeToString(it) }
companion object
val shared = LocalSignalingRelay()
private fun JsonObject.string(name: String): String = (this[name] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
internal fun normalizePublishSuffix(value: String?): String
aaaframe's normalize_shader_publish_suffix: lower case, [a-z0-9_.-], at most 48 characters.
internal fun normalizePublishSuffix(value: String?): String {
val suffix = value.orEmpty().trim().lowercase().replace('\\', '/').trim('/')
return suffix.replace(Regex("[^a-z0-9_.-]+"), "-").trim('-', '.', '_').take(48)
}
internal fun normalizeLogin(value: String): String
internal fun normalizeLogin(value: String): String =
value.trim().lowercase().replace(Regex("[^a-z0-9-]+"), "-").trim('-').ifEmpty { "field" }
internal fun publishName(login: String, suffix: String?): String
internal fun publishName(login: String, suffix: String?): String {
val normalized = normalizePublishSuffix(suffix)
return if (normalized.isEmpty()) login.lowercase() else "${login.lowercase()}/$normalized"
}
internal fun normalizePublishName(value: String?): String
aaaframe's normalize_shader_publish_name: @Alice/Left is alice/left.
internal fun normalizePublishName(value: String?): String {
val raw = value.orEmpty().trim().lowercase().trimStart('@')
val login = raw.substringBefore('/').replace(Regex("[^a-z0-9-]+"), "-").trim('-')
if (login.isEmpty()) return ""
return publishName(login, if ('/' in raw) raw.substringAfter('/') else null)
}
internal fun localWebRTCLogin(): String
The local relay's login: AFIELD_WEBRTC_LOGIN, else the OS user.
internal fun localWebRTCLogin(): String =
System.getenv("AFIELD_WEBRTC_LOGIN")?.takeIf { it.isNotBlank() } ?: System.getProperty("user.name") ?: "field"
private val httpClient: HttpClient by lazy { HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() }
internal const val LOGIN_REFUSED = "aaaframe refused the login (4401): sign in to aaaframe"
What a page and the publisher list are told when aaaframe closes a socket with 4401.
internal class UpstreamSignaling
One upstream aaaframe signalling socket. Text arrives in fragments; onText sees whole messages. Closing either side closes the other.
internal class UpstreamSignaling(
network: WebRTCNetwork,
private val onText: (String) -> Unit,
private val onClose: (String) -> Unit,
) : AutoCloseable {
private val buffer = StringBuilder()
@Volatile private var socket: WebSocket? = null
private val sendChain = Any()
private var tail: CompletableFuture<WebSocket>? = null
val opened: CompletableFuture<WebSocket>
val opened: CompletableFuture<WebSocket> = httpClient.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(10))
.apply { network.cookieHeader()?.let { header("Cookie", it) } }
.buildAsync(network.upstreamSignalingUri(), object : WebSocket.Listener {
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*>? {
buffer.append(data)
if (last) {
val text = buffer.toString()
buffer.setLength(0)
runCatching { onText(text) }
}
webSocket.request(1)
return null
}
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*>? {
// aaaframe closes with 4401 when the login is missing or has expired.
onClose(if (statusCode == 4401) LOGIN_REFUSED else "closed $statusCode $reason")
return null
}
override fun onError(webSocket: WebSocket, error: Throwable) {
onClose("error: ${error.message}")
}
}).whenComplete { ws, _ -> socket = ws }
fun send(text: String)
fun send(text: String) {
synchronized(sendChain) {
// java.net.http allows one outstanding send; chain them.
val previous = tail ?: opened
tail = previous.thenCompose { ws -> ws.sendText(text, true) }
.exceptionally { error -> lprintln("debug,webrtc", "upstream send failed: ${error.message}"); null }
}
}
override fun close()
override fun close() {
socket?.runCatching { sendClose(WebSocket.NORMAL_CLOSURE, "") }
opened.cancel(true)
}
}
internal object WebRTCConfig
What a page needs before it connects: its network's ICE servers, and for aaaframe the login it will publish under. aaaframe's answer is fetched with the session cookie and cached.
internal object WebRTCConfig {
private val cache = ConcurrentHashMap<String, JsonObject>()
fun forget(network: WebRTCNetwork) { cache.remove(network.id) }
fun forNetwork(network: WebRTCNetwork): JsonObject
fun forNetwork(network: WebRTCNetwork): JsonObject {
if (network.isLocal) return buildJsonObject {
put("network", network.id)
put("kind", "local")
put("login", normalizeLogin(localWebRTCLogin()))
put("iceServers", defaultIceServers)
}
cache[network.id]?.let { return it }
val request = HttpRequest.newBuilder(URI("${network.baseUrl}/api/shadertrinket-config"))
.timeout(Duration.ofSeconds(10))
.apply { network.cookieHeader()?.let { header("Cookie", it) } }
.GET().build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() != 200) {
return buildJsonObject {
put("network", network.id)
put("kind", "aaaframe")
put("error", "aaaframe ${network.baseUrl} answered ${response.statusCode()} to /api/shadertrinket-config" +
if (response.statusCode() == 401) "; it needs the student's session cookie (WebRTCNetwork.aaaframe(url, session = ...))" else "")
put("iceServers", defaultIceServers)
}
}
val upstream = Json.parseToJsonElement(response.body()).jsonObject
val webrtc = upstream["webrtc"]?.jsonObject
val config = buildJsonObject {
put("network", network.id)
put("kind", "aaaframe")
put("login", upstream["user"]?.jsonObject?.get("login")?.jsonPrimitive?.contentOrNull.orEmpty())
put("iceServers", webrtc?.get("iceServers")?.jsonArray ?: defaultIceServers)
}
cache[network.id] = config
return config
}
}
internal class PublisherLobby private constructor(val network: WebRTCNetwork)
The publisher names on network, kept current from the signalling broadcasts. For aaaframe this
holds one listen-only upstream socket; for the local relay it reads the relay directly.
@Volatile var names: List<String> = emptyList(); private set
@Volatile var error: String? = null; private set
private val listeners = CopyOnWriteArrayList<(List<String>) -> Unit>()
@Volatile private var upstream: UpstreamSignaling? = null
fun onChange(listener: (List<String>) -> Unit): () -> Unit {
fun onChange(listener: (List<String>) -> Unit): () -> Unit {
listeners += listener
listener(names)
return { listeners -= listener }
}
private fun update(next: List<String>) {
names = next
listeners.forEach { runCatching { it(next) } }
}
private fun start() {
if (network.isLocal) {
update(LocalSignalingRelay.shared.publishers())
LocalSignalingRelay.shared.onPublishersChanged(::update)
return
}
connectUpstream()
}
private var generation = 0
Opens a fresh upstream socket, unless one newer than after already replaced the old one.
@Synchronized
private fun connectUpstream(after: Int = generation) {
if (after != generation) return
val mine = ++generation
upstream?.close()
val current = sortedSetOf<String>()
upstream = UpstreamSignaling(network, onText = { text ->
val message = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@UpstreamSignaling
synchronized(current) {
when (message.string("type")) {
"hello" -> { current.clear(); message["publishers"]?.jsonArray?.forEach { current += it.jsonPrimitive.content } }
"publisher-online" -> current += message.string("username")
"publisher-offline" -> current -= message.string("username")
else -> return@UpstreamSignaling
}
error = null
update(current.toList())
}
}, onClose = { reason ->
if (mine == generation) {
error = reason
update(emptyList())
// Reconnect while anyone is listening; aaaframe restarts in class happen. A refused
// login waits longer, and a sign-in reconnects at once (signedIn).
val delay = if (reason == LOGIN_REFUSED) 15_000L else 3_000L
Thread({ Thread.sleep(delay); if (listeners.isNotEmpty()) connectUpstream(after = mine) }, "webrtc-lobby-retry")
.apply { isDaemon = true }.start()
}
})
}
fun signedIn()
A sign-in arrived: replace the refused socket with one carrying the new cookie, now.
fun signedIn() {
if (!network.isLocal) synchronized(this) { connectUpstream(after = generation) }
}
companion object
companion object {
private val lobbies = ConcurrentHashMap<String, PublisherLobby>()
fun of(network: WebRTCNetwork): PublisherLobby
fun of(network: WebRTCNetwork): PublisherLobby =
lobbies.computeIfAbsent(network.id) { PublisherLobby(network).also(PublisherLobby::start) }
fun existing(network: WebRTCNetwork): PublisherLobby? = lobbies[network.id]
internal fun unreachableConfig(network: WebRTCNetwork, error: Throwable): JsonObject
The config a page gets when Field cannot reach the network's server at all.
internal fun unreachableConfig(network: WebRTCNetwork, error: Throwable): JsonObject = buildJsonObject {
put("network", network.id)
put("error", "could not reach ${network.baseUrl}: ${error.message}")
put("iceServers", defaultIceServers)
}
internal fun fieldErrorMessage(reason: String): String
The message a page is sent when Field's upstream socket to aaaframe ends for a reason of its own.
internal fun fieldErrorMessage(reason: String): String =
buildJsonObject { put("type", "field-error"); put("error", reason) }.toString()