Host your Android emulator or attached Android device for agent workflows like Codex, Cursor, Claude Desktop, and browser-based QA. serve-emu streams the screen locally, over your LAN, or through your tunnel of choice, then accepts low-latency input and device-control commands over HTTP and WebSocket.
Screen.Recording.2026-06-27.at.07.57.23.mov
bunx serve-emu@latest
# or: npx serve-emu@latest
# -> Preview at http://localhost:3300Use @latest for one-off runs so Bun/npm fetches the newest published version instead of reusing a cached or locally installed copy.
By default, serve-emu starts the vendored scrcpy server on the device and
streams H.264 through an adb tunnel. Android Emulators can instead use the
built-in emulator gRPC screenshot and input APIs, with H.264 encoded by ffmpeg
on the host. Both sources feed the same WebSocket/WebCodecs or WebRTC browser
pipeline, with a Media Source Extensions fallback. Neither input path shells
out to adb shell input, keeping taps, swipes, text, and key events responsive
enough for agents.
Current package version: see packages/serve-emu/package.json and packages/serve-emu/CHANGELOG.md.
Working:
- Live H.264 video over WebSocket/WebCodecs or WebRTC, with an MSE fallback
- Per-tab switching between WebSocket and WebRTC, with lazy WebRTC startup
- Runtime switching between scrcpy and host-side gRPC screenshot capture on Android Emulators, with scrcpy or gRPC input while gRPC video is active
- Runtime PNG/MMAP selection and redacted JSON stream-stat downloads in the UI
- Tap, swipe, text, keyevent, Back, Home, Recents, and Power input
- Keyboard passthrough in the browser UI: editing/navigation keys, Ctrl/Cmd shortcuts (select all, copy, paste, cut, undo, redo), and IME composition for CJK text
- Multi-client streaming, so multiple browser tabs can share one device
- SPS/PPS replay and metadata headers for clients joining mid-stream
- Device discovery, current-device switching, and AVD start/stop controls
- Screenshot, foreground app, accessibility tree, and logcat APIs for agent inspection
- Orientation, dark/light mode, font scale, and network on/off controls
- Emulator GPS location control and route playback from GPX, GeoJSON, KML, or waypoint JSON
- Session recording and replay for REST, WebSocket, and location events
- APK install, app launch, clear data, force stop, permission grant, and media/file import helpers
- Query-scoped multi-device routing and embeddable middleware exports
Planned:
- Compiled single binary
- Bun 1.3.13+
adbon PATH from Android platform-tools- A booted device/emulator from
adb devices, or an AVD name passed with--avd - A modern browser with H.264 WebRTC support or WebCodecs; MSE is used when WebCodecs is unavailable
ffmpegwithlibx264when using--stream-mode grpc-screenshot
Node.js 18+ can invoke the published package through npx, but local development and server runtime use Bun.
The CLI remains the simplest entry point, and the fork also publishes a small typed integration surface:
serve-emuandserve-emu/middleware:createApp,createRouter, gRPC image-mode types/constants, and socket adapters for embedding the device router in another Bun/Node serverserve-emu/stream-socket: Bun andwssocket adaptersserve-emu/stream-settings: WebSocket/WebRTC settings, ICE types, defaults, and validation helpers
Unlisted deep imports such as serve-emu/src/adb.ts are blocked by the export
map. The HTTP, WebSocket, and WebRTC signaling endpoints documented below are
also supported runtime APIs.
One-off run from npm:
bunx serve-emu@latest
# or
npx serve-emu@latestLocal development from this repository:
bun install
bun run --filter serve-emu setup
bun run packages/serve-emu/src/cli.ts
# -> http://localhost:3300setup downloads the pinned scrcpy-server-v4.0 into packages/serve-emu/vendor/ and builds the browser UI. The CLI also runs the scrcpy setup lazily on first start, so you can skip the setup step for a quick local run.
serve-emu [-p <port>] [--host <addr>] [--token <secret>] [-s <serial>] [--stream-mode scrcpy|grpc-screenshot] [--grpc-image-mode png|mmap] [--input-source scrcpy|grpc] [--max-fps N] [--bit-rate N] [--max-size N] [--key-frame-interval sec] [--repeat-frame-ms ms] [--max-apk-upload-bytes N] [--max-media-upload-bytes N]
serve-emu --transport webrtc [--stun-url url[,url...]] [--turn-url url[,url...] --turn-username user --turn-credential pass]
serve-emu --avd <name> [--gpu <mode>] [--restart-avd]
serve-emu --avd-list
serve-emu --running-avds
| flag | default | meaning |
|---|---|---|
-p, --port |
3300 |
HTTP port for the preview server |
--host |
127.0.0.1 |
Address to bind. Defaults to loopback so the device is not exposed. Set 0.0.0.0 to serve over the LAN — see Access control |
--token |
none | Shared secret required on every data-bearing request. Auto-generated for non-loopback binds if omitted |
--unsafe-no-auth |
false | Allow a non-loopback bind with no authentication (dangerous) |
-s, --serial |
auto | adb device serial; required when multiple devices are online |
--stream-mode |
scrcpy |
Screen capture source: scrcpy, or emulator-only host capture through grpc-screenshot |
--grpc-image-mode |
png |
gRPC screenshot image delivery: compressed in-band png, or raw pixels through shared-memory mmap. The selected mode is strict; capture errors do not fall back to the other mode |
--input-source |
scrcpy |
Input transport for gRPC streaming: a control-only scrcpy server, or the emulator's grpc endpoint |
--max-fps |
60 |
Cap source frame rate |
--bit-rate |
8000000 |
H.264 bit rate in bps |
--max-size |
1280 |
Downscale the longest edge to N pixels; 0 keeps native size. The default balances detail and throughput, especially for the host-side software encoder used by grpc-screenshot |
--key-frame-interval |
10 |
Ask the encoder for regular keyframes; 0 disables this codec option. Late joiners get keyframes on demand, so a long interval avoids periodic keyframe bursts |
--repeat-frame-ms |
0 |
Re-encode the previous frame after N ms without screen changes (16 ≈ steady 60fps on static screens, at extra CPU/bandwidth cost); 0 keeps the source default: 100ms for scrcpy and 500ms for grpc-screenshot |
--transport |
websocket |
Initial browser video transport: websocket or webrtc. Each tab can switch independently in the UI |
--stun-url |
public STUN defaults | Comma-separated STUN URL(s) for WebRTC ICE |
--turn-url |
none | Comma-separated TURN URL(s); requires both TURN credential flags |
--turn-username |
none | TURN username |
--turn-credential |
none | TURN credential |
--webrtc-ice-policy |
all |
ICE policy: all or relay (relay requires TURN) |
--max-apk-upload-bytes |
536870912 |
Maximum APK file bytes accepted by the streaming multipart endpoint |
--max-media-upload-bytes |
1073741824 |
Maximum media/file bytes accepted by the streaming multipart endpoint |
--max-active-uploads |
2 |
Maximum upload operations reading, staging, or running through ADB concurrently |
--max-queued-uploads |
4 |
Maximum uploads waiting for an active slot; further requests receive 429 |
--upload-queue-timeout-ms |
5000 |
Maximum time an upload may wait for a slot before receiving 503 |
--avd |
none | Launch this Android Virtual Device before streaming |
--gpu |
host |
Emulator GPU mode for --avd launches. host renders on the real GPU for smooth ~60fps; see Smooth Emulator Playback |
--restart-avd |
false | Stop a running matching AVD before launching it |
--avd-list |
false | List available Android Virtual Device names |
--running-avds |
false | List currently running emulator serials and AVD names |
--emulator |
auto | Android Emulator binary path; defaults to PATH or Android SDK env vars |
--emulator-port |
auto | Android Emulator console port for --avd; must be an even port from 5554 through 5682 |
By default, serve-emu attaches to the only online device. If more than one device is online, pass -s <serial> or select another running device later through the HTTP API/UI.
serve-emu grants full control of the connected device — input, screenshots, APK installation, file import, app-data clearing, logcat, and session controls. Treat access to the port as access to the device.
Default (loopback). With no flags the server binds to 127.0.0.1, so only processes on the same machine can reach it. No authentication is required, and local CLI/agent workflows keep working with no setup. Cross-origin browser requests and WebSocket upgrades are still rejected (the Origin must match the host), so a random web page cannot drive your device through the local port.
Exposing over the LAN or a tunnel. Pass --host 0.0.0.0 (or a specific interface address). A non-loopback bind requires authentication:
- If you pass
--token <secret>, that secret is required on every data-bearing request. The origin-checkedOPTIONS /webrtc/statsCORS preflight is the only exception: it returns no statistics and lets browsers issue the bearer-authenticated cross-originGET. - If you omit
--token, a random token is generated and printed once at startup.
The startup line prints a ready-to-use URL with the token, for example:
serve-emu → http://localhost:3300/?token=qNEvGN1TSgqRc3NHeZiXOfX2tkQUnv68 (device: emulator-5554)
How clients authenticate:
- Browser (bundled UI): open the printed
?token=URL once. The server exchanges the token for aHttpOnly; SameSite=Strictsession cookie and redirects to a clean URL, so the secret is not kept in local storage or the address bar. Same-origin API, SSE, and WebSocket calls then carry the cookie automatically. - Agents / CLI (
curl, HTTP clients): sendAuthorization: Bearer <token>, or append?token=<token>to the URL.
Data-bearing requests without a valid token get 401; WebSocket upgrades and state-changing requests from a mismatched Origin get 403 before any work is done.
Unauthenticated LAN exposure. --host 0.0.0.0 --unsafe-no-auth binds to all interfaces with no authentication. Anyone who can reach the port can control the device. Only use this on a trusted, isolated network; the CLI prints a warning at startup.
Token handling. The token is never included in /health, /api responses, error payloads, or reconnect URLs — only in the one-time startup line. Rotate it by restarting with a new --token (or letting a fresh one be generated); existing cookies stop working immediately. When exposing beyond your machine, prefer an SSH tunnel or an authenticating reverse proxy over a raw 0.0.0.0 bind.
The single biggest factor for stutter-free emulator streaming is the emulator GPU mode, not the bit rate or the transport. Many AVDs default to auto, which on some hosts (notably Apple Silicon) falls back to a software Vulkan compositor (llvmpipe/lavapipe). That caps the guest at a janky ~20fps with dropped frames, so the stream stutters no matter how high you set --max-fps or --bit-rate.
serve-emu launches --avd emulators with -gpu host by default, which renders on the real GPU (Metal/Vulkan) for smooth ~60fps playback (measured: guest jank dropped from 10–19% to 0%). Override with --gpu <mode> when needed:
# default — real GPU, smooth
serve-emu --avd Pixel_8
# headless host without a usable GPU
serve-emu --avd Pixel_8 --gpu swiftshader_indirectIf you start the emulator yourself (or attach to a pre-booted one with -s), serve-emu can't set its GPU mode — launch it with -gpu host directly:
emulator @Pixel_8 -gpu hostYou can confirm the mode in the emulator log (vulkan_mode_selected:host = good; lavapipe/llvmpipe = software fallback) or via adb shell dumpsys gfxinfo <pkg> (look for a low "Janky frames" percentage while scrolling). For an extra fps margin, lower --max-size to stream at a smaller resolution.
Open http://localhost:3300 after starting the CLI. The UI streams the device into a canvas and exposes controls for:
- Pointer input, keyboard passthrough (typing, navigation keys, shortcuts, IME composition), hardware buttons, and screenshots
- Device selection plus AVD start/stop
- Stream-source switching between scrcpy and gRPC screenshot capture on emulators, with an explicit PNG/MMAP image-mode selector for gRPC
- Per-tab WebSocket/WebRTC selection and redacted stream-stat downloads
- Orientation, night mode, font scale, network, GPS location, and route playback
- Logcat filtering, pause/copy controls, app management, file import, and session replay
The browser decoder treats every WebSocket reconnect, device video session, and
hard decoder recovery as a new stream generation. Codec, latency, frame counts,
and rendered state are cleared at each boundary; the UI reports streaming
only after a frame from the current generation reaches the canvas. A connected
session with no frame becomes waiting for video, while fresh packets that do
not produce frames become stream stalled. Late events from older generations
are ignored. Input sent while the video WebSocket is disconnected is dropped
instead of being replayed against a later device session.
All examples assume the default port:
BASE=http://localhost:3300curl "$BASE/health"
curl "$BASE/api"
curl "$BASE/api/devices"
curl "$BASE/api/device-grid"
curl "$BASE/api/stream-mode"
curl -X PUT "$BASE/api/stream-mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"grpc-screenshot","grpcImageMode":"mmap","inputSource":"scrcpy"}'
curl -X POST "$BASE/api/devices/select" \
-H 'Content-Type: application/json' \
-d '{"serial":"emulator-5554"}'GET /api/stream-mode reports mode, grpcImageMode, inputSource, the
available stream and input sources, and the active session generation. PUT /api/stream-mode accepts an optional grpcImageMode of png or mmap and an
optional inputSource of scrcpy or grpc when mode is grpc-screenshot.
gRPC streaming defaults to the control-only scrcpy input transport.
Changing either value stages one replacement capture atomically; an MMAP error
is returned to the caller and never retried as PNG.
/health includes bounded subprocess executor activity, queue depth, lane
counts, deadlines, overload rejections, and output-limit totals. Device-grid
refreshes reuse one adb devices snapshot while resolving running AVD names.
Long install/import work uses a background lane; the default executor reserves
one active slot and eight queue positions for interactive work such as GPS.
The standalone CLI and exported multi-device createRouter middleware expose
the same encoder-settings endpoint. The router uses ?device=<serial> for
device-scoped requests. The standalone server applies requests to its currently
selected device and accepts ?device= when it matches that device.
curl "$BASE/api/stream-settings?device=emulator-5554"
curl -X PATCH "$BASE/api/stream-settings?device=emulator-5554" \
-H 'Content-Type: application/json' \
-d '{"maxDimension":960,"h264Bitrate":6000000,"h264Fps":60}'GET /api/stream-settings returns the active encoder settings:
{
"ok": true,
"maxDimension": 1280,
"h264Bitrate": 8000000,
"h264Fps": 60
}PATCH /api/stream-settings accepts a non-empty subset of those fields. Values
must be integers within these bounds:
| Field | Range | Meaning |
|---|---|---|
maxDimension |
0–4096 | Longest encoded edge; 0 disables the size cap |
h264Bitrate |
100000–50000000 | Target H.264 bitrate in bits per second |
h264Fps |
1–120 | Maximum encoded frames per second |
A changed setting restarts only the active capture while keeping the device's
recorded-session state. Middleware clients remain attached and resynchronize on
the replacement dimensions and a fresh keyframe. The standalone server
atomically publishes the replacement session, then reconnects existing clients.
Input is rejected while middleware capture is restarting. A failed replacement
keeps or restores the previous settings; if replay is active, PATCH returns
409 without restarting capture. Shutdown and stopped-session races return
503. Other failures use bounded JSON responses shaped as
{"ok":false,"error":"<code>"} and include a message when more detail is
available. Health includes the authoritative encoderSettings snapshot;
middleware health also includes captureRestarting.
AVD lifecycle helpers:
curl -X POST "$BASE/api/avds/start" \
-H 'Content-Type: application/json' \
-d '{"avd":"Pixel_8","select":true}'
curl -X POST "$BASE/api/avds/stop" \
-H 'Content-Type: application/json' \
-d '{"serial":"emulator-5554"}'Coordinates are normalized from 0 to 1 and converted to screen pixels by the server.
curl -X POST "$BASE/api/tap" \
-H 'Content-Type: application/json' \
-d '{"x":0.5,"y":0.5}'
curl -X POST "$BASE/api/swipe" \
-H 'Content-Type: application/json' \
-d '{"x1":0.5,"y1":0.8,"x2":0.5,"y2":0.2,"durationMs":350}'
curl -X POST "$BASE/api/text" \
-H 'Content-Type: application/json' \
-d '{"text":"hello"}'
curl -X POST "$BASE/api/key" \
-H 'Content-Type: application/json' \
-d '{"key":"back"}'Arbitrary keycodes accept an optional action ("down" or "up"; omit for an immediate press) and an optional metaState bitmask using Android's AMETA_* values (0x1 shift, 0x2 alt, 0x1000 ctrl):
# Ctrl+A (select all)
curl -X POST "$BASE/api/key" \
-H 'Content-Type: application/json' \
-d '{"keycode":29,"metaState":4096}'
# Hold DPAD_DOWN down, then release it later
curl -X POST "$BASE/api/key" -H 'Content-Type: application/json' -d '{"keycode":20,"action":"down"}'
curl -X POST "$BASE/api/key" -H 'Content-Type: application/json' -d '{"keycode":20,"action":"up"}'curl "$BASE/api/screenshot" --output screen.png
curl "$BASE/api/screenshot?format=base64"
curl "$BASE/api/foreground"
curl "$BASE/api/accessibility"
curl -X POST "$BASE/api/accessibility/tap" \
-H 'Content-Type: application/json' \
-d '{"selector":{"resourceId":"com.example:id/login"}}'
curl -X POST "$BASE/api/accessibility/tap" \
-H 'Content-Type: application/json' \
-d '{"selector":{"textContains":"Continue","clickable":true}}'
curl -N "$BASE/api/logcat?package=com.example.app&search=error"Logcat subscriptions share one adb logcat child for the active device.
New children start at the live tail instead of replaying the device's buffered
history. Matching lines are delivered in short logs SSE batches; each
subscriber has bounded line and byte queues, and batch payloads report
queue/source drop counts. /health exposes the active child, subscriber count,
queued bytes, limits, and cumulative delivery/drop totals under logcat.
Pausing Logcat in the browser closes its SSE connection, so paused panels do
not keep receiving and discarding device output.
curl "$BASE/api/orientation"
curl -X POST "$BASE/api/orientation" \
-H 'Content-Type: application/json' \
-d '{"orientation":"landscape"}'
curl "$BASE/api/night-mode"
curl -X POST "$BASE/api/night-mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"dark"}'
curl "$BASE/api/font-scale"
curl -X POST "$BASE/api/font-scale" \
-H 'Content-Type: application/json' \
-d '{"scale":1.2}'
curl "$BASE/api/network"
curl -X POST "$BASE/api/network" \
-H 'Content-Type: application/json' \
-d '{"enabled":false}'Location control uses the Android Emulator geo fix command and is currently emulator-only.
curl "$BASE/api/location"
curl -X POST "$BASE/api/location" \
-H 'Content-Type: application/json' \
-d '{"latitude":37.5665,"longitude":126.978}'Start route playback from waypoints:
curl -X POST "$BASE/api/route" \
-H 'Content-Type: application/json' \
-d '{"speedKph":30,"multiplier":1,"loop":false,"waypoints":[{"latitude":37.5665,"longitude":126.978},{"latitude":37.5651,"longitude":126.98955}]}'Read, pause, resume, or stop playback:
curl "$BASE/api/route"
curl -X POST "$BASE/api/route/control" \
-H 'Content-Type: application/json' \
-d '{"action":"pause"}'
curl -X DELETE "$BASE/api/route"The browser route importer accepts GPX, KML, GeoJSON, and waypoint JSON files up to 2 MiB. It rejects oversized files before reading them, parses in a cancellable Web Worker, and enforces the 10,000-waypoint, nesting, and complexity limits during traversal. Playback receives the complete validated waypoint sequence. Map display is separate: it caches projection by route and zoom, simplifies the line to at most 1,024 screen-space points, and pans it with one CSS transform per animation frame. The interaction target is one 16.7 ms frame at 60 Hz. Follow route is explicit; manually panning turns it off so the one-second status poll does not force the map center back onto the route.
REST and WebSocket input events are recorded by default. Add "record":false
to supported input payloads when an event should not be saved. History uses a
2,000-event, 1 MiB circular retention budget; /health contains only its
compact count/byte/replay summary. Text is normalized to scrcpy's 300-byte
UTF-8 control limit before both dispatch and recording.
curl "$BASE/api/session?limit=6"
curl "$BASE/api/session?limit=50&before=1200"
curl "$BASE/api/session/export"
curl -X POST "$BASE/api/session/replay" \
-H 'Content-Type: application/json' \
-d '{"multiplier":2}'
curl -X POST "$BASE/api/session/replay/stop"
curl -X DELETE "$BASE/api/session"Session pages are returned in chronological order with an exclusive
nextBefore cursor and hasMore flag. The bounded full history is serialized
only by the explicit export endpoint (and by the UI's Copy action), rather than
on every poll. The UI requests only its six visible recent events and pauses
polling while the Session panel or browser tab is hidden. /health exposes the
last/max UTF-8 response bytes and JSON serialization time for health, session
page, and export responses under responseMetrics. The health entry describes
the previous completed /health response because the current body is measured
after it is serialized.
curl -X POST "$BASE/api/apps/install" \
-F apk=@/path/to/app.apk
curl -X POST "$BASE/api/apps/launch" \
-H 'Content-Type: application/json' \
-d '{"packageName":"com.example.app","activity":".MainActivity"}'
curl -X POST "$BASE/api/apps/clear" \
-H 'Content-Type: application/json' \
-d '{"packageName":"com.example.app"}'
curl -X POST "$BASE/api/apps/force-stop" \
-H 'Content-Type: application/json' \
-d '{"packageName":"com.example.app"}'
curl -X POST "$BASE/api/apps/grant" \
-H 'Content-Type: application/json' \
-d '{"packageName":"com.example.app","permission":"android.permission.POST_NOTIFICATIONS"}'
curl -X POST "$BASE/api/files/import" \
-F file=@/path/to/image.pngUploads stream to private asynchronous temporary files and are removed after
ADB completes. Actual bytes are enforced even without Content-Length; a
device switch or server shutdown cancels work against the captured old device.
Oversized requests receive 413, and upload capacity errors are structured
JSON responses. /health includes current upload queue metrics.
Connect to /ws for the raw Annex-B H.264 stream. Send JSON control messages over the same socket:
{"type":"tap","x":0.5,"y":0.5}
{"type":"swipe","x1":0.5,"y1":0.8,"x2":0.5,"y2":0.2,"durationMs":350}
{"type":"text","text":"hello"}
{"type":"key","keycode":66}
{"type":"key","keycode":29,"metaState":4096}
{"type":"key","keycode":20,"action":"down"}
{"type":"back"}
{"type":"reset-video"}Use /ws?frame-meta=1 to receive a 24-byte SEMU v2 frame metadata header before each H.264 access unit: magic SEMU (4B), version=2 (1B), flags (1B, bit 0 = keyframe), reserved (2B), PTS (8B BE, µs), and the server send time (8B BE, epoch µs). Same-host clients can compare the send time against their own clock to measure transit and glass-to-glass latency. The bundled UI uses this mode to avoid per-frame NAL scans and to track PTS/keyframe/latency state.
For H.264 sources, each browser tab can independently select WebSocket or
WebRTC; --transport chooses only the initial selection for tabs without a
saved preference. The server starts its WebRTC publisher lazily on the first
authenticated, same-origin POST /webrtc/offer, and releases a viewer through
POST /webrtc/close. A WebRTC viewer keeps /ws?video=0 open as a control-only
socket, so input still travels through the active low-latency source control
path without duplicating video over WebSocket. /api exposes the default,
available viewer transports, and ICE configuration to the authenticated UI;
/health redacts TURN credentials.
The UI's Download stats action writes a versioned, redacted JSON snapshot
containing bounded viewer metrics, /health, and statistics for only the
current WebRTC session when applicable. If a server sample is unavailable, the
file is still downloaded with a safe error summary and the data that was
available. Files use schemaVersion: 1 and the name
serve-emu-<device>-<transport>-<timestamp>.json. The exporter deliberately
does not request /api, so it never collects the ICE/TURN configuration used
to initialize the viewer.
Authenticated clients can read one live viewer directly with
GET /webrtc/stats?sessionId=<uuid>; multi-device middleware also accepts an
explicit device query. Invalid requests return 400, while an unknown,
closed, or otherwise unavailable viewer returns 503. Reading stats never
starts an idle WebRTC publisher.
See the protocol reference for the complete scrcpy v3/v4 framing, control packet, and SEMU v1/v2 wire formats.
+------------------+ adb forward +-------------+ H.264 WS/RTC +---------+
| scrcpy-server.jar| <----------> | serve-emu | ------------> | Browser |
| on device | TCP tunnel | (Bun) | WebCodecs/MSE | canvas/ |
| | | | or WebRTC | video |
| - video socket | | | <------------ | |
| - control socket| | | input JSON | |
+------------------+ +-------------+ +---------+
- The CLI pushes
scrcpy-server-v4.0to/data/local/tmp/scrcpy-server.jar. - It opens
adb forward tcp:<localPort> localabstract:scrcpy_<scid>. - It spawns
app_processwith the scrcpy server class on the device, then connects video and control sockets through the tunnel. - The Bun server reads scrcpy's framed H.264 stream and publishes each access unit to active WebSocket viewers and, once requested, WebRTC viewers. Raw
/wsclients receive Annex-B payloads unchanged; the built-in WebSocket UI opts into the 24-byte frame metadata header. - The browser uses WebCodecs in a worker, falls back to MSE where necessary, or renders the WebRTC track into a
<video>. Pointer events are normalized to unit coordinates and dispatched through the active source's ordered control channel.
With --stream-mode grpc-screenshot, the emulator's gRPC endpoint provides
images while input defaults to a control-only scrcpy session and can be switched
to emulator gRPC. Set the initial choice with --input-source scrcpy|grpc.
--grpc-image-mode png
requests compressed images in the gRPC stream, while --grpc-image-mode mmap
requests raw RGB pixels through the emulator's shared-memory side channel.
serve-emu uses the bearer token advertised by the emulator's discovery file
when one is present. If an explicitly selected emulator exposes an endpoint
without a token, serve-emu prints a warning before using that local endpoint;
only select this mode for an emulator you trust.
serve-emu encodes either mode with ffmpeg/libx264 into the same Annex-B H.264
packet shape, so browser streaming, backpressure recovery, recording, and the
REST and WebSocket control APIs remain unchanged. The selected gRPC image mode
never falls back automatically. The UI can replace either source or gRPC image
mode at runtime; the current stream stays live until the replacement is ready.
For MMAP, --max-size 0 allocates the fixed shared region from the display's
native size at session startup. Rotation remains native-size, but a foldable or
resizable display that later grows beyond that startup extent requires a stream
restart so a larger region can be allocated.
MMAP support is experimental and depends on the Android Emulator build. Google
tracks an Apple Silicon streamScreenshot MMAP fix as issue
#537802959, included in
Emulator 37.2.3 Canary. If an affected emulator crashes or stops producing
frames, select PNG explicitly or upgrade to a build containing that fix.
bun install
bun run --filter serve-emu setup
bun run --filter serve-emu dev
bun run --filter serve-emu typecheck
bun run --filter serve-emu typecheck:ui
bun run --filter serve-emu build
bun run checkdev:ui proxies /api, /health, /webrtc, and /ws to
http://localhost:3300 by default. To run the backend on another port while
keeping the Vite UI on its normal development origin, start the two processes
like this:
# terminal 1: backend on a non-default port
bun run packages/serve-emu/src/cli.ts --port 4319
# terminal 2: UI with API, health, and WebSocket proxying to that backend
SERVE_EMU_BACKEND_ORIGIN=http://localhost:4319 bun run --filter serve-emu dev:uiSERVE_EMU_BACKEND_ORIGIN only selects the Vite development proxy target. It
does not disable the backend's token or same-origin protections; use the normal
CLI access-control flags when exposing the backend beyond loopback.
The repository-root README.md is the authoritative product documentation.
After editing it, regenerate and verify the package copy:
bun run docs:sync
bun run docs:checkFor runtime or protocol changes, test with a booted emulator or device:
adb devices
bun run packages/serve-emu/src/cli.tsUseful manual checks include first video frame, browser refresh recovery, multiple tabs, tap/swipe/text/key input, screenshots, logcat SSE, app management, location, route playback, and session replay.
The npm package, CLI executable, workspace, and supported import specifiers all
use the serve-emu name. Publish releases from that workspace:
npm publish --workspace packages/serve-emuSee CONTRIBUTING.md for development setup, validation steps, scrcpy protocol notes, and pull request guidelines.
Apache-2.0. Bundles the upstream scrcpy server binary (Apache-2.0) at runtime.