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.
serve-emu starts the vendored scrcpy server on the device, opens an adb forward tunnel, forwards H.264 frames over WebSockets, and decodes them in the browser with WebCodecs. Input events are written directly to scrcpy's control socket instead of shelling 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 stream from device to WebCodecs canvas
- 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
Experimental:
- Host webcam or still image as the emulator's Android camera (
--camera-back,--camera-front). The preview can be slow; see Camera
Planned:
- Multi-device routing
- Embeddable Connect-style middleware (
serve-emu/middleware) - 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 - Chrome, Edge, or Safari 16.4+ for the bundled WebCodecs UI
npx serve-emu@latest also works, but the CLI itself runs on Bun, so Bun must be installed either way.
As of 0.0.5, serve-emu is intentionally a CLI-only package. The only
supported package surface is the installed serve-emu executable. There are
no supported JavaScript or TypeScript import paths: both import "serve-emu"
and deep imports such as import "serve-emu/src/adb.ts" are blocked by the
package export map. Files shipped for the CLI are implementation details and
carry no import-path stability guarantee.
The documented HTTP and WebSocket endpoints remain supported runtime APIs once
the CLI is running; they are not package imports. The planned
serve-emu/middleware entry point is not available yet and must not be
imported until it is explicitly exported and documented in a future release.
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/, checks it against the SHA-256 from upstream's SHA256SUMS.txt, 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. A server file whose SHA-256 does not match is downloaded again and is never pushed to a device. The npm package ships the verified server.
serve-emu [-p <port>] [--host <addr>] [--token <secret>] [-s <serial>] [--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 --avd <name> [--gpu <mode>] [--restart-avd] [--camera-back <mode>] [--camera-front <mode>]
serve-emu --avd-list
serve-emu --running-avds
serve-emu --webcam-list
| 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 request. Auto-generated for non-loopback binds if omitted. Letters, digits, ., _, ~, and - only |
--unsafe-no-auth |
false | Allow a non-loopback bind with no authentication (dangerous) |
--allowed-host |
none | Without --token, also serve requests for this host name (repeatable; no wildcards), e.g. behind a reverse proxy. IP addresses, localhost, and --host are always served |
-s, --serial |
auto | adb device serial; required when multiple devices are online |
--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 emulator's software H.264 encoder sustains 60fps only below ~1 megapixel, hence the 1280 default |
--key-frame-interval |
10 |
Advisory keyframe interval; 0 omits this codec option, which leaves scrcpy's own 10 s default. The emulator's encoder counts it in frames at a nominal 60fps, so keyframes arrive later at lower frame rates. Clients get keyframes on demand via reset-video and never wait for a periodic one |
--repeat-frame-ms |
0 |
Re-encode the previous frame after N ms without screen changes; 0 keeps the encoder default of 100ms. Android repeats a frame at most 10 times, so a static screen still stops sending frames |
--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; if serve-emu started it, serve-emu exits when it exits while it is still the streamed device |
--gpu |
host (swiftshader_indirect on Linux without a display) |
Emulator GPU mode for emulators serve-emu launches (--avd and /api/avds/start). host renders on the real GPU for smooth ~60fps; see Smooth Emulator Playback |
--restart-avd |
false | Stop a running matching AVD before launching it |
--camera-back |
AVD setting | Experimental. Back camera for --avd launches, such as webcam0 for a host webcam; see Camera |
--camera-front |
AVD setting | Experimental. Front camera for --avd launches; same modes as --camera-back except virtualscene |
--avd-list |
false | List available Android Virtual Device names |
--running-avds |
false | List currently running emulator serials and AVD names |
--webcam-list |
false | Experimental. List host webcams the emulator can use, as webcam<N> and device name |
--emulator |
auto | Android Emulator binary path; defaults to PATH or Android SDK env vars |
--emulator-port |
auto | Emulator console port for --avd; must be an even port from 5554 through 5682 |
--emulator-window, --no-emulator-window |
auto | Show or hide the window of emulators serve-emu launches (--avd and /api/avds/start); see Emulator Window |
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. Browser pages still cannot drive your device through the local port:
- Cross-origin WebSocket upgrades and state-changing requests are rejected (the
Originmust match the host). - Requests whose
Hostis a DNS name are rejected with403, so a page that rebinds its own domain to127.0.0.1(DNS rebinding) is turned away. IP addresses,localhost,*.localhost, the--hostvalue, and any--allowed-hostnames are served. Behind a reverse proxy that keeps its public host name, either pass--allowed-host <name>or use--token. - Cross-site subresource requests (images, scripts,
fetchfrom another site) are rejected using the browser'sSec-Fetch-Siteheader, and so are cross-site navigations or frames that point at/api,/health, or/ws. Opening the UI from a link or in an IDE's embedded browser still works, and clients that send no such header (CLI, agents) are unaffected.
Only localhost and literal loopback addresses (127.0.0.0/8, ::1, and IPv4-mapped ::ffff:127.x.x.x) count as loopback. Any other host name is treated as non-loopback, even one that happens to resolve to 127.x.
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 request. It may contain only letters, digits,.,_,~, and-, so it passes through the URL, the session cookie, and theAuthorizationheader unchanged; serve-emu refuses to start with any other character. - 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.
Browsers decode the stream only in a secure context: localhost, 127.0.0.1, or HTTPS. Over plain HTTP on a LAN address or another host name, the controls and API work but the video shows WebCodecs unsupported; put a TLS-terminating proxy in front, or use an SSH tunnel to a local port.
Requests without a valid token get 401; WebSocket upgrades and state-changing requests from a mismatched Origin, and cross-site subresource requests, get 403 before any work is done. With a token, any host name is served: a rebound page has neither the secret nor the host-scoped cookie.
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 browser is the display, so an emulator's own window is optional.
--no-emulator-window launches emulators with -no-window, and
--emulator-window asks for the window. Without either flag, the window
opens wherever a display exists. On Linux without DISPLAY or
WAYLAND_DISPLAY (SSH sessions without X forwarding, CI, servers),
emulators launch headless, because the emulator aborts there when it tries
to open a window. A forwarded or stale DISPLAY (ssh -X, an old tmux
session) still counts as a display; pass --no-emulator-window there. The
setting, like --gpu and --emulator, applies to --avd and to emulators
started from the UI or /api/avds/start. Without a display the GPU default
is swiftshader_indirect instead of host, because the host GPU renders
through the display and the emulator cannot start without it; an explicit
--gpu still wins. It has no effect on an AVD that is already running.
# no emulator window, even on a desktop
serve-emu --avd Pixel_8 --no-emulator-windowThe 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 emulators (--avd and /api/avds/start) with -gpu host by default wherever a display exists, which renders on the real GPU (Metal/Vulkan) for smooth ~60fps playback (measured: guest jank dropped from 10–19% to 0%). On Linux without a display it uses -gpu swiftshader_indirect, since the host GPU cannot start its renderer there (see Emulator Window). Override with --gpu <mode> when needed:
# default — real GPU, smooth
serve-emu --avd Pixel_8
# a desktop 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.
Experimental. Camera support is new in 0.1.0, and the camera preview can be slow. The camera flags may change in a later release. Please report problems in GitHub issues.
An emulator can use your computer's webcam as its Android camera. List the webcams the emulator can see, then pass one when serve-emu launches the AVD:
serve-emu --webcam-list
# webcam0 FaceTime HD Camera
serve-emu --avd Pixel_8 --camera-back webcam0Open a camera app in the stream and its preview shows the webcam. Use --camera-front for apps that open the front camera. One webcam can feed only one of the two.
The emulator picks its cameras at boot, so the camera flags require --avd and don't apply to devices attached with -s. If the AVD is already running, add --restart-avd; without it, serve-emu exits with an error instead of attaching to an emulator that lacks the camera. Changing an AVD's cameras invalidates its Quick Boot snapshot, so that launch cold-boots.
The flags also accept the emulator's other camera modes, listed by emulator -help-camera-back: emulated, virtualscene (back only), none, and imagefile:<path> to show a still image, such as a QR code for a scanner test.
On macOS, the first time an Android app opens the webcam, macOS asks whether the app that launched serve-emu (usually your terminal) may use the camera. If you denied it, turn it back on under System Settings → Privacy & Security → Camera. The emulator can't open a webcam that another app is using.
Known limitations:
- The preview updates no faster than the webcam, usually 30 fps, while the rest of the stream can reach 60 fps.
- On Apple Silicon, Android Emulator 36.6 slows the preview to a few frames per second about 10 seconds after a camera app opens it, once the rest of the guest goes idle. The whole Android UI slows down too; the camera just makes it obvious. Emulator 37.2.12 holds about 30 fps, so update with
sdkmanager --install emulator. If you can't update, lower the AVD to 2 CPU cores (hw.cpu.ncore=2in itsconfig.ini). - The webcam is the one on the computer running
serve-emu. A remote viewer's browser camera can't feed the emulator. - AVDs started from the browser UI's device panel keep their own camera settings.
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
- 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.
Each display refresh draws one decoded frame. While the player keeps up (at
most two frames waiting, the older decoded less than 1.5 refresh intervals
ago), it draws the older one and keeps the next for the following refresh,
so two frames that finish decoding close together are both shown. A deeper
queue or an older frame means the player fell behind, after a stall or a
burst; it then jumps to the newest frame and drops the rest, so latency
recovers at once. The status bar's tooltip counts the frames dropped this
way as skipped.
All examples assume the default port:
BASE=http://localhost:3300Every failed /api request returns one JSON shape, with an HTTP status that
matches its code:
{ "ok": false, "error": { "code": "rate_limited", "message": "upload queue is full", "reason": "upload-queue-full" } }code is one of invalid_request (400), invalid_json (400),
unauthorized (401), forbidden (403), not_found (404),
method_not_allowed (405), conflict (409, for example a device switch
during the request), payload_too_large (413), rate_limited (429),
internal_error (500), downstream_failure (502: an adb or emulator
command failed), service_unavailable (503, including an adb command that
could not reach the device), or downstream_timeout (504: an adb command
timed out). The optional reason names a finer-grained cause within the code,
such as control-queue-overloaded.
A failed command names only the operation, such as screencap failed,
adb install timed out, or cmd uimode night failed: the device is unavailable, and its output goes to the server log with the request's method
and path. Its reason says why it failed:
reason |
code |
When |
|---|---|---|
adb-device-unavailable |
service_unavailable |
adb says the device is offline, unauthorized, not found, or its connection closed |
adb-timeout |
downstream_timeout |
the command ran past its deadline |
adb-failed |
downstream_failure |
the command failed on a reachable device |
adb-aborted |
downstream_failure |
the command was cancelled |
adb-output-limit |
downstream_failure |
the command printed more output than allowed |
adb-cleanup-failed |
downstream_failure |
a temporary file on the device could not be removed |
emulator-failed |
downstream_failure |
the emulator binary failed |
An internal_error has a fixed message, such as Internal server error. |
curl "$BASE/health"
curl "$BASE/api"
curl "$BASE/api/devices"
curl "$BASE/api/device-grid"
curl -X POST "$BASE/api/devices/select" \
-H 'Content-Type: application/json' \
-d '{"serial":"emulator-5554"}'/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.
sourceState says whether video is streaming (a frame in the last 2.5 s),
idle (no recent frame, and no reset is waiting for one), stalled (a reset
got no frame, or the first frame never came), or starting. While a stream
client is connected, the server restarts the encoder after 2.5 s without
frames to check that it is alive. Each check that the encoder answers with
only its own key frame doubles that wait, up to 30 s
(keyFrameRecovery.stallResetAfterMs). A sustained run of frames (more than
16, each within 1 s of the last) brings it back to 2.5 s. Input does not:
an action that changes nothing on screen sends no frame either, so taps and
keys never trigger a check of their own. An encoder that stops answering is
found by the next check and retried with growing gaps while sourceState
reports stalled. With no stream client connected the server sends no
checks, so a quiet source reports idle without having been checked.
Every encoder restart is counted. videoResetRequests and
lastVideoResetReason keep the total and the latest reason.
videoResetsByReason breaks the total down by reason (client opened,
client requested keyframe, client awaiting keyframe, client backpressure, first video frame not received, video source stalled,
video source idle), and videoResetsLastMinute shows a reset storm as it
happens.
contention.otherScrcpySessions counts the other scrcpy sessions on the
device: another serve-emu, or a desktop scrcpy. Each session encodes the
screen again, so every stream slows down; two sessions on one emulator
measured 17–21 fps each instead of 60. A streaming session reads the
device's /proc/net/unix 2 s after it starts and every 10 s after that, with
one bounded adb shell call. /health reports the last answer and never
waits for a probe. contention is null until the first probe answers. The
UI shows a warning in the status bar while the count is above zero.
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"}'An emulator that /api/avds/start launched belongs to the server, like a
--avd emulator belongs to the CLI: it stops when the server stops, including
while it is still booting. A client that gives up on a slow cold boot does not
cancel it; the emulator keeps booting and can be selected later. An AVD that
was already running is only attached to and is left alone. Stopping an emulator
sends emu kill and SIGTERM, then SIGKILL after 10 seconds. An emulator that
exits on its own is forgotten, so another AVD that later takes its port is not
stopped with the server.
The CLI's lifetime follows an --avd emulator it started. If that emulator
exits on its own (window closed, adb emu kill, a crash) while it is still
the streamed device, serve-emu logs the exit and stops with exit code 1,
freeing its port. It keeps running if a client switched to another device
first, or stopped that emulator through /api/avds/stop. An --avd that
was already running is only attached to, so its exit does not stop
serve-emu. When the port is already
taken by another serve-emu, startup names that server's device and status.
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 a tap, swipe, text, key, or /api/location payload when an event should not
be saved. A WebSocket touch pointer is recorded or not as a whole, decided by
its down: its moves, its up, and a disconnect release follow that choice, so
the record flag never splits a gesture. Replay likewise skips a move or up
whose down was not replayed (for example after a clear or an eviction) and
lifts any pointer still pressed when it completes or is cancelled. 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.
See the protocol reference for the complete scrcpy v3/v4 framing, control packet, and SEMU v1/v2 wire formats.
+------------------+ adb forward +-------------+ H.264 / WS +---------+
| scrcpy-server.jar| <----------> | serve-emu | ------------> | Browser |
| on device | TCP tunnel | (Bun) | WebCodecs | <canvas>|
| - video socket | | | <------------ | |
| - control socket| | | input JSON | |
+------------------+ +-------------+ +---------+
- The CLI pushes the verified
scrcpy-server-v4.0once to a content-addressed cache,/data/local/tmp/serve-emu-scrcpy-server-v4.0.jar-<SHA-256 prefix>, and copies it to a per-session working path. - 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 forwards each access unit as a binary WebSocket message. Raw
/wsclients receive Annex-B payloads unchanged; the built-in browser UI opts into the 24-byte frame metadata header. - The browser configures a
VideoDecoderfrom SPS/PPS data and draws decoded frames to a<canvas>. Pointer events are normalized to unit coordinates and encoded as scrcpy control socket packets.
bun run check enforces executed-line coverage floors, type checks, build, and package validation. Browser streaming tests separately exercise the built UI and production WebSocket handlers in Chromium, including real H.264 decoding, refresh, multi-tab device switching, input failure delivery, and slow-decoder recovery. See the browser test guide.
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 check
(cd packages/serve-emu && bunx playwright install chromium)
bun run --filter serve-emu test:browserdev:ui proxies /api, /health, 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 project and npm package are named serve-emu again, starting with version
0.0.6. If you installed the temporary serve-emul package, switch to:
bunx serve-emu@latest
# or: npx serve-emu@latestFor global installations, run npm uninstall -g serve-emul followed by
npm install -g serve-emu@latest. The executable is now serve-emu.
Development scripts use packages/serve-emu and --filter serve-emu.
Environment variables use the SERVE_EMU_ prefix.
See 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.