/** * Same-origin proxy in front of expo-device-hub. * * The hub binds loopback and is never reachable directly: serve-sim exposes a * shell-exec route and serve-emu's action routes are unauthenticated, so the * only way to a device stream is through this route, which requires an * environment session with read scope (operate scope for input and tuning). Reusing the T3 * origin is also what makes remote connections work unchanged — Tailscale and * T3 Connect already carry `/api/*` and WebSocket upgrades for the app itself. * * Only the routes the Device panel needs are forwarded. Anything under the * hub's dashboard, exec, or WebRTC surface is rejected here. */import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope, type AuthEnvironmentScope,} from "@t3tools/contracts";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import { HttpClient, HttpClientRequest, HttpRouter, HttpServerRequest, HttpServerResponse,} from "effect/unstable/http";import * as Socket from "effect/unstable/socket/Socket";import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";import { failEnvironmentAuthInvalid, failEnvironmentInternal, failEnvironmentScopeRequired,} from "../auth/http.ts";import * as DeviceService from "./DeviceService.ts"; const ALLOWED_PATHS: ReadonlyArray<RegExp> = [ /^\/api\/devices$/, /^\/vendor\/serve-sim\/api$/, /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/, /^\/vendor\/serve-sim\/appstate$/, /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/, /^\/vendor\/serve-emu\/health$/,]; /** Read paths are GET-only; only these accept other methods (screenshot captures, stream tuning). */const MUTABLE_PATHS: ReadonlyArray<RegExp> = [ /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/,]; const ALLOWED_WS_PATHS: ReadonlyArray<RegExp> = [ /^\/api\/devices\/ws$/, /^\/vendor\/serve-sim\/helper\/ws$/, /^\/vendor\/serve-emu\/ws$/,]; /** Hop-by-hop and origin headers that must not cross the proxy. */const DROPPED_REQUEST_HEADERS = new Set([ "host", "connection", "upgrade", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-protocol", "cookie", "authorization", "dpop", "content-length", "accept-encoding",]); const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => request.headers.upgrade?.toLowerCase() === "websocket"; /** * `<img>` and WebSocket cannot set headers, so every proxied request * authenticates the way the `/ws` upgrade does: a cookie for browser * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for * bearer and DPoP clients. The upgrade authenticator already implements that * fallback order, so it is used for plain requests as well. */const authenticate = (requiredScope: AuthEnvironmentScope) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catch((error) => Effect.gen(function* () { if (EnvironmentAuth.isServerAuthCredentialError(error)) { return yield* failEnvironmentAuthInvalid( EnvironmentAuth.serverAuthCredentialReason(error), EnvironmentAuth.serverAuthDpopFailureReason(error), ); } return yield* failEnvironmentInternal("internal_error", error); }), ), ); if (!session.scopes.includes(requiredScope)) { return yield* failEnvironmentScopeRequired(requiredScope); } }); const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => { const headers: Record<string, string> = {}; for (const [name, value] of Object.entries(request.headers)) { if (DROPPED_REQUEST_HEADERS.has(name) || value === undefined) continue; headers[name] = value; } // serve-emu refuses mutations whose Origin differs from the request origin. if (request.headers.origin !== undefined) headers.origin = origin; return headers;}; /** * Pipe a client WebSocket to the hub's with no framing changes. Frames are * opaque: H.264 access units one way, input packets the other. */const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string,) { const client = yield* request.upgrade; const upstream = yield* Socket.makeWebSocket(upstreamUrl, { openTimeout: "10 seconds", }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor)); yield* Effect.scoped( Effect.gen(function* () { const writeToClient = yield* client.writer; const writeToUpstream = yield* upstream.writer; const downstream = upstream.runRaw((data) => writeToClient(data)); const upstreamPump = client.runRaw((data) => writeToUpstream(data)); // Whichever side closes first ends the other via scope teardown. yield* Effect.raceFirst(downstream, upstreamPump); }), ).pipe(Effect.catchCause(() => Effect.void)); return HttpServerResponse.empty();}); const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string, hubOrigin: string,) { const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); const method = request.method; const upstreamRequest = HttpClientRequest.make(method)(upstreamUrl).pipe( HttpClientRequest.setHeaders(forwardHeaders(request, hubOrigin)), method === "GET" || method === "HEAD" ? (self) => self : HttpClientRequest.bodyStream(request.stream), ); const response = yield* httpClient.execute(upstreamRequest); const headers: Record<string, string> = {}; for (const [name, value] of Object.entries(response.headers)) { if (name === "content-encoding" || name === "transfer-encoding" || name === "connection") { continue; } if (value !== undefined) headers[name] = value; } // Long-lived MJPEG and AVCC responses must not be buffered by compression. headers["cache-control"] = "no-store, no-transform"; return HttpServerResponse.stream(response.stream, { status: response.status, headers, ...(headers["content-type"] ? { contentType: headers["content-type"] } : {}), });}); const handler = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/"; const upgrade = isWebSocketUpgrade(request); const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) => pattern.test(hubPath), ); if (!allowed) { return HttpServerResponse.text("Not Found", { status: 404 }); } const readOnly = request.method === "GET" || request.method === "HEAD"; if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) { return HttpServerResponse.text("Method Not Allowed", { status: 405 }); } const controlsDevice = (upgrade && hubPath !== "/api/devices/ws") || (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); const devices = yield* DeviceService.DeviceService; const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); if (!ready) { return HttpServerResponse.text("Device hub is not running", { status: 503 }); } // The hub runs in standalone mode at its origin root; the panel builds every // stream and socket URL itself, so nothing depends on the hub knowing the // T3 prefix. // The ticket authenticates here and must not travel on to the hub. const upstreamSearch = new URLSearchParams(url.value.search); upstreamSearch.delete("wsTicket"); upstreamSearch.delete("hostId"); const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; const upstreamPath = `${hubPath}${search}`; if (upgrade) { return yield* proxyWebSocket( request, `${ready.hub.origin.replace(/^http/, "ws")}${upstreamPath}`, ); } return yield* proxyHttp(request, `${ready.hub.origin}${upstreamPath}`, ready.hub.origin);}); export const deviceHubProxyRouteLayer = HttpRouter.add( "*", `${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`, handler,);
/**
* Same-origin proxy in front of expo-device-hub.
*
* The hub binds loopback and is never reachable directly: serve-sim exposes a
* shell-exec route and serve-emu's action routes are unauthenticated, so the
* only way to a device stream is through this route, which requires an
* environment session with read scope (operate scope for input and tuning). Reusing the T3
* origin is also what makes remote connections work unchanged — Tailscale and
* T3 Connect already carry `/api/*` and WebSocket upgrades for the app itself.
*
* Only the routes the Device panel needs are forwarded. Anything under the
* hub's dashboard, exec, or WebRTC surface is rejected here.
*/
import {
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
type AuthEnvironmentScope,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import {
HttpClient,
HttpClientRequest,
HttpRouter,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http";
import * as Socket from "effect/unstable/socket/Socket";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import {
failEnvironmentAuthInvalid,
failEnvironmentInternal,
failEnvironmentScopeRequired,
} from "../auth/http.ts";
import * as DeviceService from "./DeviceService.ts";
const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
/^\/api\/devices$/,
/^\/vendor\/serve-sim\/api$/,
/^\/vendor\/serve-sim\/api\/screenshot$/,
/^\/vendor\/serve-sim\/api\/event-log(\/events)?$/,
/^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/,
/^\/vendor\/serve-sim\/appstate$/,
/^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/,
/^\/vendor\/serve-emu\/health$/,
];
/** Read paths are GET-only; only these accept other methods (screenshot captures, stream tuning). */
const MUTABLE_PATHS: ReadonlyArray<RegExp> = [
/^\/vendor\/serve-sim\/api\/screenshot$/,
/^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/,
];
const ALLOWED_WS_PATHS: ReadonlyArray<RegExp> = [
/^\/api\/devices\/ws$/,
/^\/vendor\/serve-sim\/helper\/ws$/,
/^\/vendor\/serve-emu\/ws$/,
];
/** Hop-by-hop and origin headers that must not cross the proxy. */
const DROPPED_REQUEST_HEADERS = new Set([
"host",
"connection",
"upgrade",
"sec-websocket-key",
"sec-websocket-version",
"sec-websocket-extensions",
"sec-websocket-protocol",
"cookie",
"authorization",
"dpop",
"content-length",
"accept-encoding",
]);
const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
request.headers.upgrade?.toLowerCase() === "websocket";
/**
* `<img>` and WebSocket cannot set headers, so every proxied request
* authenticates the way the `/ws` upgrade does: a cookie for browser
* sessions, or a short-lived `wsTicket` minted over authenticated HTTP for
* bearer and DPoP clients. The upgrade authenticator already implements that
* fallback order, so it is used for plain requests as well.
*/
const authenticate = (requiredScope: AuthEnvironmentScope) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (EnvironmentAuth.isServerAuthCredentialError(error)) {
return yield* failEnvironmentAuthInvalid(
EnvironmentAuth.serverAuthCredentialReason(error),
EnvironmentAuth.serverAuthDpopFailureReason(error),
);
}
return yield* failEnvironmentInternal("internal_error", error);
}),
),
);
if (!session.scopes.includes(requiredScope)) {
return yield* failEnvironmentScopeRequired(requiredScope);
}
});
const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(request.headers)) {
if (DROPPED_REQUEST_HEADERS.has(name) || value === undefined) continue;
headers[name] = value;
}
// serve-emu refuses mutations whose Origin differs from the request origin.
if (request.headers.origin !== undefined) headers.origin = origin;
return headers;
};
/**
* Pipe a client WebSocket to the hub's with no framing changes. Frames are
* opaque: H.264 access units one way, input packets the other.
*/
const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* (
request: HttpServerRequest.HttpServerRequest,
upstreamUrl: string,
) {
const client = yield* request.upgrade;
const upstream = yield* Socket.makeWebSocket(upstreamUrl, {
openTimeout: "10 seconds",
}).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor));
yield* Effect.scoped(
Effect.gen(function* () {
const writeToClient = yield* client.writer;
const writeToUpstream = yield* upstream.writer;
const downstream = upstream.runRaw((data) => writeToClient(data));
const upstreamPump = client.runRaw((data) => writeToUpstream(data));
// Whichever side closes first ends the other via scope teardown.
yield* Effect.raceFirst(downstream, upstreamPump);
}),
).pipe(Effect.catchCause(() => Effect.void));
return HttpServerResponse.empty();
});
const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* (
request: HttpServerRequest.HttpServerRequest,
upstreamUrl: string,
hubOrigin: string,
) {
const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient);
const method = request.method;
const upstreamRequest = HttpClientRequest.make(method)(upstreamUrl).pipe(
HttpClientRequest.setHeaders(forwardHeaders(request, hubOrigin)),
method === "GET" || method === "HEAD"
? (self) => self
: HttpClientRequest.bodyStream(request.stream),
);
const response = yield* httpClient.execute(upstreamRequest);
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(response.headers)) {
if (name === "content-encoding" || name === "transfer-encoding" || name === "connection") {
continue;
}
if (value !== undefined) headers[name] = value;
}
// Long-lived MJPEG and AVCC responses must not be buffered by compression.
headers["cache-control"] = "no-store, no-transform";
return HttpServerResponse.stream(response.stream, {
status: response.status,
headers,
...(headers["content-type"] ? { contentType: headers["content-type"] } : {}),
});
});
const handler = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}
const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/";
const upgrade = isWebSocketUpgrade(request);
const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) =>
pattern.test(hubPath),
);
if (!allowed) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const readOnly = request.method === "GET" || request.method === "HEAD";
if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) {
return HttpServerResponse.text("Method Not Allowed", { status: 405 });
}
const controlsDevice =
(upgrade && hubPath !== "/api/devices/ws") ||
(!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
const devices = yield* DeviceService.DeviceService;
const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
if (!ready) {
return HttpServerResponse.text("Device hub is not running", { status: 503 });
}
// The hub runs in standalone mode at its origin root; the panel builds every
// stream and socket URL itself, so nothing depends on the hub knowing the
// T3 prefix.
// The ticket authenticates here and must not travel on to the hub.
const upstreamSearch = new URLSearchParams(url.value.search);
upstreamSearch.delete("wsTicket");
upstreamSearch.delete("hostId");
const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
const upstreamPath = `${hubPath}${search}`;
if (upgrade) {
return yield* proxyWebSocket(
request,
`${ready.hub.origin.replace(/^http/, "ws")}${upstreamPath}`,
);
}
return yield* proxyHttp(request, `${ready.hub.origin}${upstreamPath}`, ready.hub.origin);
});
export const deviceHubProxyRouteLayer = HttpRouter.add(
"*",
`${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`,
handler,
);