@expo/hub-client

中文文档
1.12.2发布于 2026/11/14MITTypeScriptES Module构建来源已验证近 30 天下载 5.6万

Device-client React hooks and types for mirroring live iOS simulators and Android emulators in the browser through Expo Device Hub.

原始使用文档
文档目录17 节
  1. Requirements
  2. Install
  3. Connect to a device
  4. Add the provider
  5. Read state and controls
  6. Read device settings
  7. Show the screen
  8. Show the screen in its device shape
  9. Fold a foldable device
  10. Show each display of a foldable
  11. Show CPU usage
  12. Keep updates focused
  13. Connect to a token-gated Hub
  14. Call device controls
  15. Remote connections
  16. iOS embedding
  17. License

@expo/hub-client

Render live iOS simulators and Android emulators in a React app. Connect through Expo Device Hub, show the device with DeviceScreen, and send touch, keyboard, and device controls.

Requirements

  • A running Expo Device Hub server. Either start an Expo app that has the expo-device-hub DevTools plugin installed, or run npx expo-device-hub standalone. The provider connects to the Hub's /vendor/serve-sim and /vendor/serve-emu routes.
  • React 18 or newer.
  • A browser to show the video and send input.

Install

npm install @expo/hub-client

Connect to a device

Wrap your screen, controls and metrics in DeviceClientProvider. They share one connection, and each component reads the client properties it needs.

Add the provider

Wrap the components that need the device connection. The components below all use this provider:

import { DeviceClientProvider } from "@expo/hub-client";

export function LiveSession({ udid }: { udid: string }) {
  return (
    <DeviceClientProvider
      key={udid}
      platform="ios"
      options={{
        baseUrl: "http://localhost:3400/vendor/serve-sim",
        device: udid,
        streamMode: "webrtc",
      }}
    >
      <Screen />
      <Controls />
      <Cpu />
    </DeviceClientProvider>
  );
}

For Android, use platform="android", your serve-emu URL, and an adb serial such as 'emulator-5554' for device.

When using the Expo DevTools plugin, the server URL includes /_expo/plugins/expo-device-hub before /vendor/serve-sim or /vendor/serve-emu.

streamMode is required: 'mjpeg', 'h264', or 'webrtc'. iOS supports all three. Android maps a mode it cannot serve to one it can.

Use one provider per session. When switching sessions, set its key to the session ID so the old connection closes. Set options.enabled to false to disconnect, or use platform={null} while no device is selected.

Read state and controls

Destructure everything you need from one useDeviceClient() call:

import { useDeviceClient } from "@expo/hub-client";

function Controls() {
  const { status, pressButton, rotate, reload } = useDeviceClient();
  const connected = status === "streaming";

  return (
    <div>
      <span>{status}</span>
      <button disabled={!connected} onClick={() => pressButton("home")}>
        Home
      </button>
      <button disabled={!connected} onClick={rotate}>
        Rotate
      </button>
      <button disabled={!connected} onClick={reload}>
        Reload
      </button>
    </div>
  );
}

Status and control changes update this component. FPS, metrics, and log changes do not. Status can be 'idle', 'connecting', 'reconnecting', 'streaming' or 'error'. 'connecting' means that the screen has not shown a frame yet: show loading UI. It becomes 'error' after 30 seconds without a frame, and the client continues to retry. After the first frame, a lost stream is 'reconnecting': the last frame stays, so show no loading UI. On iOS, a reconnect that lasts 30 seconds becomes 'error', and the client continues to retry. framePresented tells whether the screen has shown a frame. A new device, server, token, transport, or display surface starts again at 'connecting'. Read error for the last failure message.

Read device settings

deviceSettingsStatus reports settings availability on both platforms, independently of video and app activity. It is 'idle' while disabled, 'loading' during discovery and the first read, then 'ready' or 'error'. Settings reads time out after five seconds; 'ready' can include empty settings.

import { useDeviceClient } from "@expo/hub-client";

function DeviceSettingsSummary() {
  const { deviceSettingsStatus, deviceSettings } = useDeviceClient();
  if (deviceSettingsStatus === "idle") return null;
  if (deviceSettingsStatus === "loading") return <span>Loading settings…</span>;
  if (deviceSettingsStatus === "error") return <span>Settings unavailable</span>;

  return <span>Appearance: {deviceSettings?.appearance ?? "Unavailable"}</span>;
}

Failed refreshes report 'error' and retain cached values. Use 'ready' to enable controls; recovery restores it without showing the initial loading state again.

Show the screen

Use useDeviceScreenClient with DeviceScreen. It follows screen and input changes without updating for metrics or logs. DeviceScreen fills its parent, so give the parent a size and position: relative. displayScreen gives you the screen size after accounting for rotation. DeviceScreen draws no background, loading state, or error: draw them yourself, over it, from status, error, and framePresented.

import { DeviceScreen, displayScreen, useDeviceScreenClient } from "@expo/hub-client";

function Screen() {
  const client = useDeviceScreenClient();
  const screen = displayScreen(client.screen);
  const aspectRatio = screen ? `${screen.width} / ${screen.height}` : "9 / 19.5";
  const loading = client.status !== "error" && client.status !== "idle" && !client.framePresented;

  return (
    <div style={{ position: "relative", width: 360, aspectRatio, background: "#eee" }}>
      <DeviceScreen client={client} />
      {loading && <Spinner />}
      {client.status === "error" && <p role="alert">{client.error}</p>}
    </div>
  );
}

Show the screen in its device shape

Import DeviceShape from @expo/hub-client/shapes to clip the screen to the shape of its device: the corners of each iPhone, iPad, and Pixel model, and the shape of the display that an iPhone Duo shows. Like DeviceScreen, it takes the client, and it turns with the device. It only clips, so it draws nothing and pointer input keeps working. A model without a known shape gets no clip. Apps that do not import @expo/hub-client/shapes do not bundle the shapes.

import { DeviceScreen, useDeviceClient } from "@expo/hub-client";
import { DeviceShape, useScreenShape } from "@expo/hub-client/shapes";

function ShapedScreen({ model }: { model: string }) {
  const client = useDeviceClient();
  // The shape as the viewer sees it, turned with the device, for the box's proportions.
  const shape = useScreenShape({ client, model });
  const aspectRatio = shape ? `${shape.width} / ${shape.height}` : "9 / 19.5";
  return (
    <div style={{ position: "relative", width: 360, aspectRatio }}>
      <DeviceShape client={client} model={model} style={{ position: "absolute", inset: 0 }}>
        <DeviceScreen client={client} />
      </DeviceShape>
    </div>
  );
}

The client's device name is the default model, but a simulator can report its name late. Pass model when you know it: the simulator device type name on iOS, such as "iPhone 17 Pro", or the AVD hardware profile id on Android, such as "pixel_9". platform, display, orientation, and shape also override the client, and without a client they are the only source, for example for a recording:

<DeviceShape
  platform="ios"
  model="iPhone Duo"
  display="inner"
  style={{ position: "absolute", inset: 0 }}
>
  <video src={recordingUrl} />
</DeviceShape>

useScreenShape returns the same shape for layout, such as the box's aspect ratio above. The clip keeps the corners right for the shape's own proportions, so give the box those proportions, or pass the box's aspectRatio to DeviceShape. screenShapeClipPath returns the CSS clip-path for an element that you cannot wrap.

Fold a foldable device

client.fold has the fold state of a foldable device, such as the iPhone Duo, and is null for other devices. client.capabilities.fold lists the commands that the device accepts: an angle range, the named poses with their hinge angles, and Table Mode. It is false when the device offers no fold commands. Send only what it lists.

import { useDeviceClient } from "@expo/hub-client";

function FoldButtons() {
  const { fold, capabilities } = useDeviceClient();
  const controls = capabilities.fold;
  if (!fold || !controls) return null;
  const range = controls.angle;

  return (
    <>
      <span>
        {fold.angle ?? "?"}° {fold.pose ?? "custom"} {fold.pending ? "(moving)" : ""}
      </span>
      {controls.poses.book && (
        <button onClick={() => fold.setControl({ control: "pose", value: "book" })}>Book</button>
      )}
      {range && (
        <button onClick={() => fold.setControl({ control: "angle", value: range.max })}>
          Most open
        </button>
      )}
      {controls.tableMode && fold.tableMode?.available && (
        <button onClick={() => fold.setControl({ control: "tableMode", value: true })}>
          Table Mode
        </button>
      )}
    </>
  );
}

fold.tableMode and fold.physical are present only on a device that has them. The poses share one vocabulary, FOLD_POSES: closed, open, laptop, book, and tent. fold.observe reports the steps of each fold command, from its request to its outcome, whichever control sent it. A queued command that a newer one replaces gets only its request.

Show each display of a foldable

On an iPhone Duo, client.displays lists the cover and the inner display. Attach one element per display, of the kind client.videoKind names, to stream each display yourself. While any display has an element, the client parks the flat stream, and status follows the active display. Send input to a display with its id, in that display's coordinates. The client drops a gesture that begins on a display that the device does not present (client.screen.display).

import { useDeviceClient } from "@expo/hub-client";

function Displays() {
  const client = useDeviceClient();
  // A tap in the middle of the inner display.
  const tapInner = () => {
    client.sendTouch({ phase: "begin", display: "inner", x: 0.5, y: 0.5 });
    client.sendTouch({ phase: "end", display: "inner", x: 0.5, y: 0.5 });
  };

  return (
    <>
      {client.displays?.map((display) =>
        client.videoKind === "video" ? (
          <video key={display.id} ref={display.attachVideo} autoPlay muted playsInline />
        ) : client.videoKind === "canvas" ? (
          <canvas key={display.id} ref={display.attachVideo} />
        ) : (
          <img key={display.id} ref={display.attachVideo} alt="" />
        ),
      )}
      <button onClick={tapInner}>Tap the inner display</button>
    </>
  );
}

deviceModelUrl is a 3D model of the device, Xcode's for the iPhone Duo. The Hub's 3D preview uses it with displays and fold.observe.

Show CPU usage

Use useDeviceClientSelector when you need one value from a larger object. This component updates when the latest CPU percentage changes, even if memory or network readings change.

import { useDeviceClientSelector } from "@expo/hub-client";

function Cpu() {
  const cpuPct = useDeviceClientSelector((client) => client.activity?.samples.at(-1)?.cpuPct);

  return <span>{cpuPct == null ? "Waiting for metrics" : `CPU ${cpuPct.toFixed(1)}%`}</span>;
}

Reading activity with useDeviceClient would update the component whenever any part of activity changes.

Keep updates focused

  • Read state in your component body so it updates with the device. Data read only in an event handler can be out of date. Controls such as client.rotate() use the latest callback.
  • Pick named properties when destructuring. Spreading the client or using { status, ...rest } subscribes to every property. Reading activity.samples subscribes to the whole activity object.
  • Keep selectors simple. Return a number, string, boolean, or an existing object. If you build a new object, pass a comparison function as the second argument.
  • Treat the client and its data as read-only.

Connect to a token-gated Hub

A Hub started with npx expo-device-hub --require-token needs its session token on every request. The Hub dashboard itself needs nothing extra: opening the link with the token gives the browser a cookie that covers every request. A page on another origin has no such cookie, so pass the token to the provider:

<DeviceClientProvider
  key={udid}
  platform="ios"
  options={{
    baseUrl: "http://127.0.0.1:3400/vendor/serve-sim",
    device: udid,
    streamMode: "mjpeg",
    token,
  }}
>
  <Screen />
</DeviceClientProvider>

For Android, use platform="android" and the /vendor/serve-emu URL. The client sends the token as Authorization: Bearer <token>, as a serve-sim.token.<token> or serve-emu.token.<token> WebSocket subprotocol, and as ?token= only where a browser cannot set a header (the MJPEG <img>, EventSource).

The token does not replace CORS. Start the Hub with --cors-origin and the origin of your page, once for each origin:

npx expo-device-hub --require-token --cors-origin https://example.com

The flag applies to iOS and Android. Without it, only a page on a loopback origin, such as http://localhost:5173, can read the Hub's responses. For Android, the Hub must also listen on a loopback address. On that page, Android refuses HTTP requests that change device state, such as rotate, screenshot, and device settings. iOS refuses serve-sim's control socket, which carries logs, events, metrics, device settings, location, and app actions.

Call device controls

Every control lives on the DeviceClient. Controls are no-ops while nothing is connected, and each backend ignores the buttons its platform does not have.

// Hardware buttons: 'home' | 'back' | 'recents' | 'power' | 'appSwitcher' | 'hideKeyboard'
client.pressButton("home");

client.rotate();
// client.capabilities.orientation is false, or { orientations: DeviceOrientation[] } with the
// poses that setOrientation accepts. client.orientation shows the choice at once, then the
// backend's report; read client.orientationPending and client.orientationError like location.
client.setOrientation("landscape_left");
// Observed iOS devices separate app screenOrientation from physical deviceOrientation.
// A null observation is unknown; omitted fields mean the helper lacks this contract.
// screen.orientation follows the app interface in this mode; displayScreen/streamGeometry
// use physical pose for framing and map video/input accordingly. Duo and older helpers
// retain their existing geometry. client.orientation follows physical pose on observed iOS,
// including Duo, so portrait-only apps do not reset the Rotate cursor;
// Native-pose Duo keeps its clockwise physical order; older Duo uses legacy names.
// Ordinary observed iOS uses CLOCKWISE_ORIENTATIONS; older helpers use DEVICE_ORIENTATIONS.
// framesUpright means the backend already sends upright frames (serve-emu).
// While an Android recording could break, client.recordingLock is "status-unknown" or
// "recording" and rotate, setOrientation and the stream setters are silently ignored. Disable
// those controls from it. client.orientationUnavailable gives the reason orientation cannot
// change now, also while the iOS input socket cannot take input.
client.reload(); // reload the running React Native bundle
client.setAppearance("dark"); // 'light' | 'dark'; read it back from client.appearance

// { blob, artifact, metadata? }, or null when capture fails. `artifact` is the session artifact
// outcome: { status: 'saved' } | { status: 'disabled' } | { status: 'failed', error?: string }
// | null (null for a backend that does not report one). `metadata` is the capture's
// { width, height, displayRotation, screen? }; it is absent on older servers, and a zero width
// or height or a null displayRotation means unknown.
const capture = await client.screenshot();
if (capture?.artifact?.status === "failed")
  console.warn("not saved to session artifacts", capture.artifact.error);

// Input is normalized to 0..1 of the screen, so it works for every device size.
client.sendTouch({ phase: "begin", x: 0.5, y: 0.5 });
client.sendTouch({ phase: "end", x: 0.5, y: 0.5 });
client.sendKey({ phase: "down", code: "KeyA", key: "a", repeat: false });
client.sendKey({ phase: "up", code: "KeyA", key: "a", repeat: false });

// Logs are off until you attach them.
client.attachLogs();
client.logs; // DeviceLog[]
client.detachLogs();

Optional features such as device settings, camera feeds, the accessibility tree, location, and app permissions are only available on some backends. Check client.capabilities before you show their controls. The full DeviceClient contract, with a comment on every field, is in src/types.ts.

Remote connections

Set options.baseUrl on DeviceClientProvider to the remote streaming server:

  • iOS: https://hub.example.test/device-hub/vendor/serve-sim
  • Android: https://hub.example.test/device-hub/vendor/serve-emu

iOS embedding

When embedding the iOS screen on another site, pass the public serve-sim mount that serves /api and /helper as baseUrl, for example https://sim.example.test/preview/session. The stream and input URLs then use that server. Start serve-sim with --cors-origin <origin> for the origin of the embedding page, for example --cors-origin http://localhost:8081. Without it, the exec-ws socket closes, and logs, events, metrics and UI requests stop, even when both servers run on localhost.

The foreground app icon does not need exec-ws. hub-client reads it from serve-sim's /api/apps/icon route, a plain GET, when /api advertises the route. Older servers send the icon over exec-ws.

A serve-sim started with --require-token, such as an EAS Simulator Preview session, needs its session token on every request. Pass it as options.token:

<DeviceClientProvider
  key={udid}
  platform="ios"
  options={{
    baseUrl: "https://sim.example.test/preview/session",
    device: udid,
    streamMode: "mjpeg",
    token,
  }}
>
  <Screen />
</DeviceClientProvider>

The token does not replace --cors-origin.

License

MIT

版本与发布记录

@expo/hub-client 共发布 169 个版本,首次发布于 2026/09/16;过去 12 个月发布了 169 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.12.2latest2026/11/14今天
1.12.2-canary-20261113-1e5f890canary2026/11/14今天
1.12.2-canary-20261113-e1691412026/11/14今天
1.12.2-canary-20261113-ab2ba422026/11/131 天前
1.12.2-canary-20261113-9ea899f2026/11/131 天前
1.13.0-canary-20261112-7e85a192026/11/121 天前

关于 @expo/hub-client 的常见问题

@expo/hub-client 是什么?

Device-client React hooks and types for mirroring live iOS simulators and Android emulators in the browser through Expo Device Hub.

如何安装 @expo/hub-client?

在项目目录运行 npm install @expo/hub-client,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=18.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@expo/hub-client 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@expo/hub-client 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@expo/hub-client 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@expo/hub-client 可以商用吗?

@expo/hub-client 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@expo/hub-client 的下载量有多少?

根据 npm 官方下载统计,@expo/hub-client 在 2026/10/14 – 2026/11/12 这 30 天内被下载 56,297 次,日均约 1,877 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@expo/hub-client 还在维护吗?

最新版本 1.12.2 发布于 2026/11/14(今天);至今共发布 169 个版本,其中过去 12 个月发布了 169 个;首次发布于 2026/09/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。