NetherNet transport
Started 2026-11-10. Work happens on dev/nethernet-transport, tracked in a draft pull request. This plan is a working document: tick slices off, and add or reshape them as the work teaches us things.
Goal
Accept Bedrock clients over NetherNet, the WebRTC transport that Bedrock Dedicated Server (BDS) has defaulted to since 1.26.50, alongside RakNet. Make the server visible on the LAN again. Leave the transport seam generic enough that a Java Edition transport can be added later without reshaping it.
Requirements from the request:
- Use
nethernet(npmnethernet, PrismarineJS's node-nethernet) for the WebRTC side. No longer holds, from 2026-11-11: ADR 0007 replaced it with our own protocol code, and ADR 0006 moved the WebRTC stack from werift to libdatachannel (webrtc-backend.md). enable-raknetandenable-nethernetconfig keys, both on by default, so both transports run side by side.enable-lan-visibility, named as BDS names it and on by default, as BDS ships it.
Status
Updated 2026-11-11: NetherNet now runs on NetherNetServer and NetherNetClient in @jsprismarine/nethernet, over libdatachannel in @jsprismarine/native-network; werift, npm nethernet and protodef are gone. port is server-port, and with the defaults RakNet and NetherNet share its UDP port. The rest of this section is as of 2026-11-10, and names werift and nethernet where they were.
As of 2026-11-10. Both transports run side by side, on by default:
- RakNet works as before, now behind the transport-neutral seam, and also listens on IPv6 at
server-portv6. - NetherNet joins work two ways: by address over HTTP signaling on the TCP side of
port, and on the local network over UDP 7551, where the server also advertises itself.server-udp-portsconstrains and advertises its UDP ports. - Our own client joins our server and Bedrock Dedicated Server 1.26.51.1 over NetherNet, by address, in offline and online mode, and spawns.
- A retail client (iOS 1.26.62, on mobile data) joins our server by address over NetherNet, logs in online and spawns, then disconnects within seconds with
ClientDisconnection-90. See slice 17. - Left before the pull request merges: the cinnabar run (slice 9) and the rearchitecture plan note (slice 10). The retail post-spawn disconnect (slice 17) can follow in its own pull request if it turns out not to be NetherNet's.
What we know
Facts gathered on 2026-11-10, from the sources listed.
BDS 1.26.51.1, run locally with transport=nethernet:
- It listens on TCP
server-port(dual-stack) for HTTP signaling, and on UDP 7551 for LAN discovery. Gameplay runs over UDP ports that WebRTC allocates per client. GET /v1/joinanswers200withapplication/json:{"name":"Dedicated Server","protocol":2193,"version":"1.26.51","level":"Bedrock level","players":0,"maxPlayers":10,"gameType":0}. Any other path answers404text/plainNot Found.POST /v1/join/{networkId}answers200application/sdp. An offer withouta=identitygot the body37, not an SDP answer. That code is not in the error tables we have, so its meaning is open.server.propertiesdocumentsenable-lan-visibility=true: "Listen and respond to clients that are looking for servers on the LAN." Withtransport=raknet, BDS also binds the default ports (19132, 19133) for LAN even whenserver-portdiffers.
Mojang's NetherNet HTTP signaling guide:
- The client probes
GET /v1/join. A 2xx means NetherNet, and the JSON fills the server list. Any other status means the client does not try WebRTC. - The client POSTs its complete SDP offer (full ICE, no trickle) with
Content-Type: application/sdpto/v1/join/{networkId}, wherenetworkIdis the client's own ID as an opaque string. The response body is the complete SDP answer. One request per attempt, no retry. - The client creates the data channels:
ReliableDataChannel(ordered, reliable) andUnreliableDataChannel(unordered,maxRetransmits: 0). Each message starts with a byte that counts the fragments still to follow. - The answer must carry a session-level
a=identity: base64 of{"idp":{"domain":…,"protocol":"default"},"assertion":"<JSON string>"}, where the assertion holdstoken(a JWT self-signed with the key in itscpkclaim, a JWK) andfingerprints(a detached JWS over canonical{"fingerprint":[{"algorithm":"sha-256","digest":"<SDP fingerprint>"}]}). The client rejects an answer without one. Over plain HTTP the client pins thecpkdigest and prompts the player the first time it sees a key, so the server key must persist across restarts. - The offer carries the client's own
a=identitywith a GameServerToken. Verifying it is "a server policy decision". - The guide leaves open: the outer base64 variant, the JWS signature encoding, and how a server learns its reflexive address behind NAT.
Serverdoes LAN discovery and signaling on UDP 7551 (listen()), answering discovery requests with the buffer given tosetAdvertisement. It does not implement HTTP signaling, buthandleOffer(signal, respond)andhandleCandidateaccept signals from any channel. Werift waits for ICE gathering, so the answer SDP already lists its candidates.- Connections are keyed by a
bigintconnection ID, not an IP address.encapsulateddelivers reassembled messages. - Pure JavaScript WebRTC through werift by default. Needs Node 24 or newer; the workspace requires 26.4.
bedrock-protocol 3.61, the other nethernet consumer:
- Over NetherNet a batch has no
0xFEheader. bedrock-protocol's client still readsServerToClientHandshakeand answers it, but never turns encryption on; DTLS covers the wire. (The hill climb below confirmed BDS does the same.) - The LAN advertisement is version 7: varint-prefixed strings and zigzag varints for MOTD, protocol, game version, level name, player counts, game mode, editor and hardcore flags, accepted auth kinds, a nonce, and a connection type.
cinnabar, the client the e2e harness drives, joins addressed servers over NetherNet through a go-nethernet fork. Its source answers the encoding questions above, and it can verify the join end to end.
This repository, when the work started:
packages/prismarine/src/network/transport/Transport.tsalready defines the seam the rearchitecture plan describes. It still speaks RakNet'sInetAddressinonDisconnect,onFrameandonRaw, andClientConnectiontakes aRakNetTransportConnection.BatchCodecandEncryptionCodecin@jsprismarine/protocolread and write the0xFEbyte, and@jsprismarine/clientuses them too.- The query protocol rides RakNet's raw datagrams.
Design
- The server names every connection. A
ConnectionIdis allocated once per accepted connection from oneConnectionIdsallocator that every transport of a server shares: 1, 2, 3 in accept order, never reused. The session registry, the per-connection frame queues, and packet traces and their replay all key on it. A transport's own peer key (RakNet'saddress:port, the connection ID a NetherNet client picks) stays private to that transport, inKeyedTransport. So two transports can never hand the dispatcher the same identity, a client reconnecting from the same port is a new connection, and a trace captured on one server replays against a fresh one because both number connections the same way.MultiTransportowns the allocator and hands it to the transports it builds, so no wiring can give two of them separate sequences. - Transport-neutral seam. Frames and disconnects carry the
ConnectionId; each connection describes its peer with anInetAddress, used as a plain IP-and-port value. Raw datagrams (onRaw) are optional, and only RakNet provides them. - Frames are Bedrock batches as RakNet carries them, starting with
0xFE. The NetherNet transport adds the byte to what it receives and strips it from what it sends, so the protocol codecs and the client package stay as they are. A Java transport would carry Java packet frames to its own session pipeline; the seam only moves opaque frames and never interprets them. - Capabilities describe the transport, the login follows BDS.
capabilities.encryptedsays DTLS covers the wire: the login still sendsServerToClientHandshakebut leaves encryption off, as BDS does over NetherNet.maxFrameSizeisnethernet's message limit, which its fragmenting allows. - One dispatcher, several transports. A
MultiTransportmerges the enabled transports into oneTransport, soNetworkDispatcherserves them all through one subscription. - Networking owns the listeners.
ServerNetworkingstarts each enabled transport and the LAN advertiser from config, and keeps the advertisement current as players join and leave. - Shared parts get one home. What two transports or two advertisers would both write lives in one module:
Subscriptionruns one event's listeners;BaseTransportholds subscriptions and per-connection ordering;KeyedTransportmaps wire keys to connection IDs; one server status snapshot (MOTD, level, version, player counts, game mode) feeds the RakNet pong, the LAN advertisement andGET /v1/join; the data channel's fragment framing stays innethernet. - One package per transport.
@jsprismarine/nethernetsits beside@jsprismarine/raknetand holds what the server and the client share: signaling, identities, the LAN advertisement and the ES384 JWS. Each side's transport adapts it to Bedrock batches. A Java transport gets its own package the same way. - The advertised player count has one owner.
ServerNetworkingcounts live connections across transports and callsServerName.ownPlayerCount(), so RakNet's sockets stop setting it. - Signaling sits apart from the WebRTC peer. The HTTP signaling server on TCP
server-portand the LAN signaling on UDP 7551 both feed the samenethernetServerthroughhandleOffer. The answer getsa=identityfrom a server identity key stored beside the world data.
Slices
Each slice is one or more small commits that leave the tree building, linted and tested. Check off a slice when it lands on the branch.
- BDS answers
37to an offer withouta=identityand accepts a self-signed client identity (base64 DERcpk, domainself). - With LAN visibility off, BDS answers
GET /v1/joinwith200and an empty body, so the probe treats any 2xx as NetherNet and the status as optional. - BDS sends
ServerToClientHandshakeover NetherNet but keeps batches in the clear; an encryptedClientToServerHandshakestalls the login. Client and server now both leave encryption off on an encrypting transport. Next rungs: online mode (slice 16), and a real game client through cinnabar (slice 9).
- BDS answers
- Corrected 2026-11-11: webrtc-backend.md slice 9 replaced this. NetherNet runs on libdatachannel, a single port serves every player, and the setting and
server-public-ipfollow Bedrock Dedicated Server 1.26.70.24.
- Corrected 2026-11-11: webrtc-backend.md slice 9 replaced this. NetherNet runs on libdatachannel, a single port serves every player, and the setting and
- Online BDS still accepts a self-signed offer identity; it refuses the login with
NOT_AUTHENTICATEDuntil the Login packet carries an Xbox Live chain. - Our client, signed in through prismarine-auth, joins online BDS and our online server with the chain and the multiplayer token, and
NetherNetIdentitycan carry that token in the offer instead of a self-signed one. - Our server answers offers without checking the token, as go-nethernet leaves identity to the Minecraft layer, and checks the Login packet as it does over RakNet.
- Online BDS still accepts a self-signed offer identity; it refuses the login with
Open questions
- BDS answers
37(200 application/sdp) to an offer withouta=identity. Our signaling answers a refused offer with a non-2xx status, as go-nethernet does; whether game clients treat BDS's form differently is untested. - What
ClientDisconnection-90means, and whether it is NetherNet's (slice 17).
Future work
Not needed for a viable NetherNet transport, but known and worth doing:
- A real game client in CI. Once cinnabar joins over NetherNet, add a client e2e scenario that names the transport, so a RakNet fallback cannot hide a NetherNet regression. Then mark NetherNet done in the rearchitecture plan.
- Joining from outside the local network. Beyond
server-udp-ports: STUN to learn a reflexive address without configuring one, and TURN for players behind symmetric NAT. Mojang's guide advises against STUN and TURN for HTTP signaling, so start with the static mapping. - Verifying the offer's token. A server could check the multiplayer token in
a=identitybefore answering, and refuse a join before WebRTC starts. Nothing needs it yet: the Login packet is checked either way. - Friends and Realms joins. BDS signs in to Microsoft's signaling service at startup ("Signed in to signaling service successfully"), which is how friends join a server they have not typed the address of. That needs an Xbox Live identity for the server.
- LAN discovery in the client.
@jsprismarine/clientjoins by address only;LanDiscoveryin@jsprismarine/nethernetnow finds servers on the local network, and the client does not use it yet. - NetherNet load. Whether the WebRTC stack keeps up with many players. Measure by counts (messages, bytes, allocations, tasks), not wall-clock time, as AGENTS.md asks, and decide from that whether NetherNet needs a worker thread like RakNet's.
- A LAN peer's candidates.
NetherNetServer, likenethernetbefore it, keys peers on the connection ID the client picks, and applies aCANDIDATEADDto whichever peer that ID names, whoever sent it. Check whether one LAN peer can disturb another's negotiation. - A Java Edition transport. The seam is ready for one: a
KeyedTransportover TCP drawing from the sameConnectionIds, in a package of its own, feeding a Java session pipeline rather than the Bedrock dispatcher. - Dropped: binding RakNet's default port as well when
portdiffers, as BDS does withtransport=raknet. Current clients discover LAN games over NetherNet.