Engineering · Casting series, part 2 of 4
WebRTC Signaling Over the Cast Custom Namespace
Establishing a sub-second WebRTC stream to a Chromecast without a signaling server — and the four handshake bugs that fail in total silence.
This is part 2 of a four-part series drawn from shipping Chromecast casting in Remotype, the app that turns your phone into a keyboard, trackpad, and remote for every computer you own.
- Part 1: Fundamentals and Launching Your Own Receiver
- Part 2: WebRTC Signaling Over the Cast Custom Namespace (you are here)
- Part 3: Resolution, Aspect Ratio, and the Silent Decoder Cliffs
- Part 4: Audio, Adaptive Bitrate, and Production Hardening
2.1 The receiver page: 130 lines, no build step
The entire receiver is one HTML file with a <video> element and the CAF (Cast Application Framework) receiver SDK. The essential skeleton:
<video id="v" autoplay playsinline></video>
<script src="//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<script>
const NS = 'urn:x-cast:com.custavia.remotype.cast';
const context = cast.framework.CastReceiverContext.getInstance();
let pc = null, token = null, senderId = null;
function send(obj) {
if (senderId) try { context.sendCustomMessage(NS, senderId, obj); } catch (e) {}
}
context.addCustomMessageListener(NS, (e) => {
senderId = e.senderId;
const m = e.data;
if (!m || !m.token) return;
if (token && m.token !== token) return; // session pin — see 2.4!
if (m.kind === 'offer' && m.data) {
token = m.token;
pc = new RTCPeerConnection({ iceServers: [] }); // LAN-only: no STUN/TURN needed
pc.ontrack = (ev) => { v.srcObject = ev.streams[0]; };
pc.onicecandidate = (ev) => ev.candidate && send({ kind:'ice', token, data: ev.candidate });
pc.setRemoteDescription(new RTCSessionDescription(m.data))
.then(() => pc.createAnswer())
.then(ans => pc.setLocalDescription(ans).then(() => send({ kind:'answer', token, data: ans })));
} else if (m.kind === 'ice' && pc) {
pc.addIceCandidate(new RTCIceCandidate(m.data)).catch(() => {});
}
});
// Announce readiness the moment a sender connects (see 2.3 for why this matters).
context.addEventListener(cast.framework.system.EventType.SENDER_CONNECTED, (e) => {
senderId = e.senderId;
send({ kind: 'ready' });
});
const opts = new cast.framework.CastReceiverOptions();
opts.disableIdleTimeout = true; // we never LOAD CAF media — don't let the platform reap us
opts.customNamespaces = {};
opts.customNamespaces[NS] = cast.framework.system.MessageType.JSON;
context.start(opts);
</script>
Notes worth their weight:
iceServers: []is correct, not lazy. Sender and TV share a LAN; host candidates are all you need, and skipping STUN removes a failure class and a latency source.disableIdleTimeout: trueis mandatory for a non-media receiver. CAF assumes you'llLOADmedia through its player; if you don't, it kills your app after a few idle minutes — mid-cast.- The receiver can only reply to a sender it has heard from.
sendCustomMessageneeds asenderId, which the page only learns from an inbound event. This asymmetry is the root of a deadlock we dissect next.
2.2 Silent failure #1: your CASTv2 library may be eating the receiver's replies
This is the bug we most want to spare you, because nothing surfaces it: the receiver launches, your offer goes out, and the answer simply never arrives. Every log on both sides looks healthy.
Most CASTv2 libraries were built to talk to Google's namespaces, where every message carries a "type" field. go-chromecast (a fine library otherwise) enforces that in its receive pump:
messageType, err := jsonparser.GetString(payload, "type")
if err != nil {
c.log("could not find 'type' key in response message ...")
return // ← your receiver's reply dies here, invisibly
}
Our custom-namespace protocol used {"kind":"answer", ...} — no "type" key — so every reply the receiver sent was discarded inside the transport library before our code ever saw it. Google's own broadcasts (RECEIVER_STATUS, MEDIA_STATUS) carry "type" and flowed through happily, which made the connection look alive while our handshake starved.
The fix is a two-line patch (vendor the library, forward un-typed frames on custom namespaces), but the lesson generalizes:
Lesson: audit your CASTv2 library's receive path for schema assumptions before designing your namespace protocol — or simply include a
"type"field in your messages and sidestep the whole class.
2.3 Silent failure #2: the offer/page-load race — and why the receiver can't save you
Recall from Part 1: LAUNCH returns when the app is allocated, not when the page is listening. A real Chromecast takes two to four seconds to fetch and boot the page. Our first implementation sent the offer on a blind two-second timer — and lost the race just often enough to look haunted.
Worse: this deadlock is unrecoverable by design if you only send the offer once. The receiver's send() helper is gated on senderId, which it learns only from an inbound message. A dropped offer means no inbound message, which means the receiver cannot even tell you it's ready — both sides wait forever, each believing the other is about to speak.
The robust pattern has three parts:
// 1. The receiver announces {kind:"ready"} on SENDER_CONNECTED (it learned our
// senderId from the platform event, not from a message — breaking the cycle).
// 2. The sender waits for `ready` before offering, with a short blind fallback
// for older receiver versions that never announce.
// 3. The sender RE-SENDS the offer only after a long silence — see 2.4 for why
// "only after a long silence" is load-bearing.
select {
case <-s.readyCh: // receiver said it's listening
case <-time.After(2 * time.Second): // fallback for receivers that don't
}
s.sendOffer()
2.4 Silent failure #3: an eager retry that murders its own success
Our first retry loop re-sent the offer every 1.5 seconds until answered. Under test, casts began failing with a new signature: an answer would arrive and be rejected with
invalid proposed signaling state transition: stable->SetRemote(answer)->stable
followed by a stream of ICE candidates dropped for a ufrag mismatch. Here's the mechanism, because it's a beautiful little trap:
- Offer #1 arrives; the receiver builds a peer connection and starts answering.
- Your 1.5-second timer fires; offer #2 arrives. The receiver tears down and rebuilds its peer connection (that's the correct handling of a new offer).
- Answer #1 — already in flight — arrives at the sender and is accepted. The state machine is now
stable. - Answer #2 arrives. Setting a second answer on a
stableconnection is illegal; rejected. - The receiver now trickles ICE candidates for connection #2, whose credentials (
ufrag) don't match the connection #1 your sender committed to. Every candidate is dropped. ICE starves. The cast dies with every individual component reporting success.
Lesson: an offer is not idempotent. Each one resets the receiver. Send exactly one once the receiver is ready, and re-send only after enough silence (we use 12 seconds) that the first can be presumed dead — with a hard deadline (45 s) that triggers your fallback path.
2.5 Silent failure #4: session pins that outlive the session
That if (token && m.token !== token) return; line in the receiver is a security measure — once a session establishes, messages from a different session are ignored (a rogue LAUNCH of your public App ID shouldn't be able to hijack a live cast). But we initially never cleared the pin, and disableIdleTimeout keeps the page resident between casts. Result: the first cast after the page loads works; every subsequent cast is silently ignored — its offer carries a fresh token, fails the pin, and the guard's early-return means not even an error goes back.
Clear the pin at every session boundary:
context.addEventListener(EventType.SENDER_CONNECTED, (e) => { token = null; teardown(); senderId = e.senderId; send({kind:'ready'}); });
context.addEventListener(EventType.SENDER_DISCONNECTED, () => { token = null; teardown(); });
// and on your explicit {kind:"stop"} message
2.6 Make the receiver report its own failures
The TV has no console you can reach (production Chromecasts don't expose remote DevTools). A receiver that fails inside a promise chain and paints "Couldn't start" on the screen is invisible to your sender-side logs. Report every step back over the namespace:
function reportError(stage, err) {
send({ kind:'error', token, stage, message: String(err && err.message || err).slice(0,300) });
}
send({ kind: 'accepted', token }); // offer taken, building pc
pc.setRemoteDescription(desc).catch(e => { reportError('setRemoteDescription', e); throw e; })
.then(() => pc.createAnswer().catch(e => { reportError('createAnswer', e); throw e; }))
...
v.onplaying = () => send({ kind:'playing', token,
info: v.videoWidth + 'x' + v.videoHeight }); // ← [Part 3](/google-cast-integration-part-3-resolution-decoder-limits/) hinges on this
That last message — fired only when the <video> element renders its first actual frame, carrying the decoded dimensions — turns out to be the single most valuable diagnostic in the whole system. Here's why.
Built by Custavia. Remotype's keyboard and trackpad are free forever; casting is part of Remotype Pro. If this series saves your team the weeks it cost ours, it did its job — and we'd love to hear what you build with it.