Skip to content

Vendor LiveKit WebRTC and add WebRTC transport#3

Open
sjchmiela wants to merge 18 commits into
mainfrom
stanley/expo-serve-sim-03-vendor-livekit-webrtc
Open

Vendor LiveKit WebRTC and add WebRTC transport#3
sjchmiela wants to merge 18 commits into
mainfrom
stanley/expo-serve-sim-03-vendor-livekit-webrtc

Conversation

@sjchmiela

@sjchmiela sjchmiela commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the first grouped WebRTC PR in the new stack.

Commits:

  • Vendor LiveKit WebRTC framework and native build integration
  • Add WebRTC transport and signaling
  • Add WebRTC codec selection and fallback
  • Improve WebRTC ICE and TURN handling
  • Fix packaged WebRTC H264 runtime behavior for Tart VMs

Notes:

  • The LiveKit vendoring commit uses the final SwiftPM binary artifact approach. It does not include the earlier checked-in LiveKitWebRTC.framework source vendoring flow.
  • Tunnel-provider support is intentionally not included in this stack.

Validation

  • ./node_modules/.bin/tsc --noEmit
  • npm exec --yes --package bun@latest -- bun run packages/serve-sim/build.ts

@sjchmiela sjchmiela force-pushed the stanley/expo-serve-sim-02-tunnel-support branch from ca949d8 to 1151105 Compare June 30, 2026 08:46
@sjchmiela sjchmiela force-pushed the stanley/expo-serve-sim-03-vendor-livekit-webrtc branch from 18472b9 to 198d472 Compare June 30, 2026 08:46
## Summary
- Renames the package/workspace to `@expo/serve-sim` for the Expo fork
@sjchmiela sjchmiela force-pushed the stanley/expo-serve-sim-02-tunnel-support branch 4 times, most recently from 152c36f to ad92736 Compare June 30, 2026 12:27
@sjchmiela sjchmiela force-pushed the stanley/expo-serve-sim-03-vendor-livekit-webrtc branch from 198d472 to 222f286 Compare June 30, 2026 12:34
@sjchmiela sjchmiela changed the base branch from stanley/expo-serve-sim-02-tunnel-support to main June 30, 2026 12:34
@sjchmiela sjchmiela marked this pull request as ready for review June 30, 2026 12:34
@szdziedzic szdziedzic self-requested a review June 30, 2026 15:13
try {
const body = await readRequestBody(req);
const offer = JSON.parse(body.toString("utf8")) as unknown;
const answer = this.capture.handleWebRTCOffer(offer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This WebRTC offer path is synchronous all the way into native code. handleWebRTCOffer() calls this.capture.handleWebRTCOffer(offer) on the Node request path, and the native implementation blocks on a semaphore while WebRTC creates the answer and gathers ICE. That means one /webrtc/offer request can stall the whole server event loop for seconds, including unrelated HTTP requests, WS input, and stream fan-out. This should be exposed as an async native method, similar to the AX async methods, so signaling work does not block Node.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4de4d70. SimCapture.handleWebRTCOffer is now an async Node method and the JS NativeCapture/DeviceSession path awaits the returned Promise, so /webrtc/offer signaling no longer blocks the Node request path while native creates the answer / gathers ICE.

}

export function mjpegStreamUrlFrom(config: NonNullable<Window["__SIM_PREVIEW__"]>): string {
return `${config.url.replace(/\/+$/, "")}/stream.mjpeg`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This derives the MJPEG URL from config.url, but in embedded/non-proxy mode config.url is only the helper origin while config.streamUrl contains the required /helper/<udid>/stream.mjpeg path. That regresses MJPEG fallback for the Expo CLI integration path: fallback will request /stream.mjpeg at the root and get a 404/blank video. We should keep using config.streamUrl for MJPEG, or derive from streamUrl rather than url, and add a test using the inProcessServeSimState() shape.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 76f77db. mjpegStreamUrlFrom() now derives from config.streamUrl and swaps the terminal stream endpoint to /stream.mjpeg, preserving embedded /helper/<udid>/... paths. Added a regression test using inProcessServeSimState().

case "/stream.avcc": session.handleAvcc(req, res); return true;
case "/config": session.handleConfig(req, res); return true;
case "/health": session.handleHealth(req, res); return true;
case "/webrtc/offer": void session.handleWebRTCOffer(req, res); return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/webrtc/offer needs an explicit CORS preflight path. The browser sends POST with Content-Type: application/json, so cross-origin helper usage will issue an OPTIONS preflight first. Today this route falls through to handleWebRTCOffer(), which tries to parse the empty OPTIONS body as an offer and returns an error instead of a 204/200 with CORS headers. This will make WebRTC fail before signaling in direct helper mode.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 76f77db. /webrtc/offer now handles OPTIONS with a 204 and CORS headers before reading/parsing a body or starting the helper session. Added a focused CORS header test.

setDataChannelOpen(false);
}
};
pc.ontrack = (event) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Receiving ontrack clears the failed codec state before we know any frames are actually decodable. If negotiation succeeds but the browser cannot decode the produced bitstream, the UI can end up permanently black: the connection may be connected, ontrack fires, but no frame ever renders and the codec ladder never advances. The AVCC path has a startup/no-frame watchdog; WebRTC needs a similar first-frame watchdog using requestVideoFrameCallback, loadeddata, or decoded-frame stats.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8930a8c. ontrack now starts a first-frame watchdog instead of clearing codec failure immediately. SimulatorView marks success only after requestVideoFrameCallback or loadeddata, and the hook fails the current codec if no decoded frame arrives.

// drop to MJPEG, which every helper serves. See avcc-fallback.ts.
const avcc = useAvccStream();
const streamSettings = config.streamSettings;
const useWebRtcVideo = streamSettings?.transport === "webrtc";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once transport === "webrtc", the UI stays pinned to the WebRTC path even after the codec fallback ladder is exhausted. If VP8/VP9/H264 all fail, nextWebRtcFallbackCodec() returns null, but we never drop to MJPEG as the terminal safety net. That leaves users with a dead/black stream instead of the existing reliable fallback.

I don't know if this is something we care about or overcomplication. Maybe this is too much overthinking? Maybe we should explicitly fail? I think I prefer explicit failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8930a8c. I kept the transport pinned to WebRTC and made exhaustion explicit: once the codec ladder is out of attempts, the simulator view shows a WebRTC failure instead of silently dropping to MJPEG.


private let deviceUDID: String
private let onFrame: SimFrameCallback
private let webRTCPublisher = WebRTCPublisher()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This eagerly constructs WebRTCPublisher for every capture engine, including pure HTTP/MJPEG sessions. That constructor builds WebRTC factory state and probes H.264 support, so the HTTP path pays WebRTC startup cost even when WebRTC is never requested. Can we lazy-create this only when the first WebRTC offer arrives?

I'm not sure if we care about it here tbh

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4de4d70. CaptureEngine now lazy-creates WebRTCPublisher on the first WebRTC offer, so HTTP/MJPEG-only sessions do not pay the WebRTC factory/probe cost during capture startup.

}

let h264Request = reserveH264EncodeIfNeeded()
let shouldSendWebRTC = webRTCPublisher.isActive

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks webRTCPublisher.isActive on every captured frame, and isActive synchronously hops onto the publisher queue. That adds a cross-thread sync to the hot frame path even when the selected transport is HTTP/MJPEG. A cached atomic/state flag, or gating this by configured transport, would avoid the per-frame synchronization.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4de4d70. The frame hot path now checks a cached webRTCActive flag updated by WebRTCPublisher.onActiveChanged; it no longer calls webRTCPublisher.isActive and synchronously hops onto the publisher queue for every frame.

private var encodedCount: Int64 = 0
private let stateQueue = DispatchQueue(label: "serve-sim.webrtc.h264.encoder")

init(codecInfo: LKRTCVideoCodecInfo) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The software H.264 encoder logs the negotiated codec parameters, but the actual VideoToolbox encoder still hardcodes High profile. If the browser answers constrained-baseline/profile-level-id parameters, we may emit SPS/PPS that do not match the negotiated profile, especially on the VM path where this encoder matters most. We should either honor the negotiated profile-level-id or avoid accepting profiles the encoder will not produce.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4de4d70. The custom software H.264 encoder factory now filters/rejects H.264 capabilities unless profile-level-id is High profile (64...), so baseline/constrained-baseline answers are not negotiated against the VideoToolbox encoder that emits High-profile SPS/PPS.

@sjchmiela sjchmiela requested a review from szdziedzic July 2, 2026 09:37

@szdziedzic szdziedzic left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work 👏

I have 2 cleanup/nit comments but it LGTM overall

}
}

func handleOffer(_ request: WebRTCOfferPayload) throws -> WebRTCAnswerPayload {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new async handleOffer(...) path appears to be the only remaining call site, so can we remove this old synchronous overload? Keeping the semaphore-based version around makes it easier for a future call site to accidentally reintroduce the JS-thread blocking behavior this update just fixed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 63f3ed3. Removed the old semaphore-based synchronous handleOffer(...) overload; the async path is now the only offer handling API.

return try result!.get()
}

func handleOffer(_ request: WebRTCOfferPayload) async throws -> WebRTCAnswerPayload {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The async native path fixes the Node event-loop blocking issue, but this continuation still depends on libwebrtc eventually invoking one of the signaling callbacks. ICE gathering has its own timeout later, but setRemoteDescription, answer, and setLocalDescription do not appear to have a native-side timeout. If one of those callbacks is dropped or stalls, the /webrtc/offer request can remain pending indefinitely. Could we add a signaling timeout/cleanup here, or otherwise guarantee these callbacks always resolve?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 63f3ed3. Added a native-side 10s signaling timeout guarded by a one-shot completion helper, so stalled/dropped setRemoteDescription / answer / setLocalDescription callbacks reject the offer and close the active session instead of leaving /webrtc/offer pending indefinitely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants