1/**
2 * Same-origin proxy in front of expo-device-hub.
3 *
4 * The hub binds loopback and is never reachable directly: serve-sim exposes a
5 * shell-exec route and serve-emu's action routes are unauthenticated, so the
6 * only way to a device stream is through this route, which requires an
7 * environment session with read scope (operate scope for input and tuning). Reusing the T3
8 * origin is also what makes remote connections work unchanged — Tailscale and
9 * T3 Connect already carry `/api/*` and WebSocket upgrades for the app itself.
10 *
11 * Only the routes the Device panel needs are forwarded. Anything under the
12 * hub's dashboard, exec, or WebRTC surface is rejected here.
13 */
14import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
15import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";
17import {
18 HttpClient,
19 HttpClientRequest,
20 HttpRouter,
21 HttpServerRequest,
22 HttpServerResponse,
23} from "effect/http";
24import * as Socket from "effect/socket/Socket";
25import * as NodeSocket from "@effect/platform-node/NodeSocket";
26
27import { authenticateMediaRequest } from "../auth/http.ts";
28import * as DeviceService from "./DeviceService.ts";
29
30const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
31 /^\/api\/devices$/,
32 /^\/vendor\/serve-sim\/api$/,
33 /^\/vendor\/serve-sim\/api\/screenshot$/,
34 /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/,
35 /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/,
36 /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/,
37 /^\/vendor\/serve-sim\/appstate$/,
38 /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/,
39 /^\/vendor\/serve-emu\/health$/,
40];
41
42/** Read paths are GET-only; only these accept other methods (screenshot captures, stream tuning). */
43const MUTABLE_PATHS: ReadonlyArray<RegExp> = [
44 /^\/vendor\/serve-sim\/api\/screenshot$/,
45 /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/,
46 /^\/vendor\/serve-emu\/api\/fold$/,
47];
48
49const ALLOWED_WS_PATHS: ReadonlyArray<RegExp> = [
50 /^\/api\/devices\/ws$/,
51 /^\/vendor\/serve-sim\/helper\/ws$/,
52 /^\/vendor\/serve-emu\/ws$/,
53];
54
55/** Hop-by-hop and origin headers that must not cross the proxy. */
56const DROPPED_REQUEST_HEADERS = new Set([
57 "host",
58 "connection",
59 "upgrade",
60 "sec-websocket-key",
61 "sec-websocket-version",
62 "sec-websocket-extensions",
63 "sec-websocket-protocol",
64 "cookie",
65 "authorization",
66 "dpop",
67 "content-length",
68 "accept-encoding",
69]);
70
71const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
72 request.headers.upgrade?.toLowerCase() === "websocket";
73
74const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {
75 const headers: Record<string, string> = {};
76 for (const [name, value] of Object.entries(request.headers)) {
77 if (DROPPED_REQUEST_HEADERS.has(name) || value === undefined) continue;
78 headers[name] = value;
79 }
80 // serve-emu refuses mutations whose Origin differs from the request origin.
81 if (request.headers.origin !== undefined) headers.origin = origin;
82 return headers;
83};
84
85/**
86 * Pipe a client WebSocket to the hub's with no framing changes. Frames are
87 * opaque: H.264 access units one way, input packets the other.
88 */
89const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* (
90 request: HttpServerRequest.HttpServerRequest,
91 upstreamUrl: string,
92) {
93 const client = yield* request.upgrade;
94 const upstream = yield* Socket.makeWebSocket(upstreamUrl, {
95 openTimeout: "10 seconds",
96 }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor));
97 yield* Effect.scoped(
98 Effect.gen(function* () {
99 const writeToClient = yield* client.writer;
100 const writeToUpstream = yield* upstream.writer;
101 // Whichever side closes first ends the other via scope teardown: a close
102 // fails the pull with a SocketError, which loses the race.
103 return yield* Effect.raceFirst(
104 pumpFrames(upstream, writeToClient),
105 pumpFrames(client, writeToUpstream),
106 );
107 }),
108 ).pipe(Effect.ignoreCause);
109 return HttpServerResponse.empty();
110});
111
112const pumpFrames = (source: Socket.Socket, sink: Socket.Writer) =>
113 Effect.gen(function* () {
114 const { pull } = yield* source.reader;
115 while (true) {
116 yield* sink.writeAll(yield* pull);
117 }
118 });
119
120const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* (
121 request: HttpServerRequest.HttpServerRequest,
122 upstreamUrl: string,
123 hubOrigin: string,
124) {
125 const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient);
126 const method = request.method;
127 const upstreamRequest = HttpClientRequest.make(method)(upstreamUrl).pipe(
128 HttpClientRequest.setHeaders(forwardHeaders(request, hubOrigin)),
129 method === "GET" || method === "HEAD"
130 ? (self) => self
131 : HttpClientRequest.bodyStream(request.stream),
132 );
133 const response = yield* httpClient.execute(upstreamRequest);
134 const headers: Record<string, string> = {};
135 for (const [name, value] of Object.entries(response.headers)) {
136 if (name === "content-encoding" || name === "transfer-encoding" || name === "connection") {
137 continue;
138 }
139 if (value !== undefined) headers[name] = value;
140 }
141 // Long-lived MJPEG and AVCC responses must not be buffered by compression.
142 headers["cache-control"] = "no-store, no-transform";
143 return HttpServerResponse.stream(response.stream, {
144 status: response.status,
145 headers,
146 ...(headers["content-type"] ? { contentType: headers["content-type"] } : {}),
147 });
148});
149
150const handler = Effect.gen(function* () {
151 const request = yield* HttpServerRequest.HttpServerRequest;
152 const url = HttpServerRequest.toURL(request);
153 if (Option.isNone(url)) {
154 return HttpServerResponse.text("Bad Request", { status: 400 });
155 }
156 const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/";
157 const upgrade = isWebSocketUpgrade(request);
158 const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) =>
159 pattern.test(hubPath),
160 );
161 if (!allowed) {
162 return HttpServerResponse.text("Not Found", { status: 404 });
163 }
164 const readOnly = request.method === "GET" || request.method === "HEAD";
165 if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) {
166 return HttpServerResponse.text("Method Not Allowed", { status: 405 });
167 }
168 const controlsDevice =
169 (upgrade && hubPath !== "/api/devices/ws") ||
170 (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath));
171 yield* authenticateMediaRequest(
172 controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope,
173 );
174 const devices = yield* DeviceService.DeviceService;
175 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
176 if (!ready) {
177 return HttpServerResponse.text("Device hub is not running", { status: 503 });
178 }
179 // The hub runs in standalone mode at its origin root; the panel builds every
180 // stream and socket URL itself, so nothing depends on the hub knowing the
181 // T3 prefix.
182 // The ticket authenticates here and must not travel on to the hub.
183 const upstreamSearch = new URLSearchParams(url.value.search);
184 upstreamSearch.delete("wsTicket");
185 upstreamSearch.delete("hostId");
186 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
187 const upstreamPath = `${hubPath}${search}`;
188 if (upgrade) {
189 return yield* proxyWebSocket(
190 request,
191 `${ready.hub.origin.replace(/^http/, "ws")}${upstreamPath}`,
192 );
193 }
194 return yield* proxyHttp(request, `${ready.hub.origin}${upstreamPath}`, ready.hub.origin);
195});
196
197export const layer = HttpRouter.add("*", `${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`, handler);