| 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 | */ |
| 14 | import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import { |
| 18 | HttpClient, |
| 19 | HttpClientRequest, |
| 20 | HttpRouter, |
| 21 | HttpServerRequest, |
| 22 | HttpServerResponse, |
| 23 | } from "effect/http"; |
| 24 | import * as Socket from "effect/socket/Socket"; |
| 25 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 26 | |
| 27 | import { authenticateMediaRequest } from "../auth/http.ts"; |
| 28 | import * as DeviceService from "./DeviceService.ts"; |
| 29 | |
| 30 | const 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). */ |
| 43 | const 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 | |
| 49 | const 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. */ |
| 56 | const 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 | |
| 71 | const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => |
| 72 | request.headers.upgrade?.toLowerCase() === "websocket"; |
| 73 | |
| 74 | const 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 | */ |
| 89 | const 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 | |
| 112 | const 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 | |
| 120 | const 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 | |
| 150 | const 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 | |
| 197 | export const layer = HttpRouter.add("*", `${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`, handler); |