From a4586c422a23251a77ccdaba4a47d9feebb5bebd Mon Sep 17 00:00:00 2001 From: abcdmku <63693423+abcdmku@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:21:40 -0500 Subject: [PATCH] feat(mobile): preview localhost dev servers with screenshot annotation A paired mobile client can now open a dev server bound to the environment's loopback through the existing T3 connection (LAN, Tailscale, or T3 Connect), capture the viewport, annotate it with numbered pins/boxes and notes, and add the flattened screenshot plus notes to the thread draft. Server: preview.listLocalServers and preview.createProxyTicket RPCs, an HMAC-signed single-use entry ticket exchanged for an HttpOnly session cookie, and a global middleware that proxies the whole origin (documents, assets, fetch, WebSocket HMR) to the validated loopback port. Requests carrying T3 credentials or reserved T3 paths always bypass the proxy, and T3 cookies are stripped from forwarded headers. Mobile: ThreadPreview screen (picker, WebView, capture via react-native-view-shot, annotation editor) attaching through the existing composer image flow, gated on the new previewProxy capability. Co-Authored-By: Claude Fable 5 --- apps/mobile/package.json | 1 + apps/mobile/src/Stack.tsx | 9 + .../preview/ThreadPreviewRouteScreen.tsx | 682 ++++++++++++++++++ .../preview/previewAnnotation.test.ts | 99 +++ .../src/features/preview/previewAnnotation.ts | 159 ++++ .../features/threads/ThreadRouteScreen.tsx | 25 + apps/mobile/src/state/preview.ts | 5 + apps/server/src/auth/EnvironmentAuth.ts | 2 +- apps/server/src/auth/RpcAuthorization.ts | 4 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/preview/ProxyAccess.test.ts | 141 ++++ apps/server/src/preview/ProxyAccess.ts | 237 ++++++ apps/server/src/preview/ProxyRoutes.test.ts | 268 +++++++ apps/server/src/preview/ProxyRoutes.ts | 362 ++++++++++ apps/server/src/server.ts | 11 + apps/server/src/ws.ts | 17 + docs/README.md | 2 + docs/internals/glossary.md | 19 + docs/internals/preview-proxy.md | 58 ++ docs/user/mobile-preview.md | 35 + packages/client-runtime/src/state/preview.ts | 16 + packages/contracts/src/environment.test.ts | 13 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/preview.ts | 56 ++ packages/contracts/src/rpc.ts | 20 + pnpm-lock.yaml | 53 ++ 26 files changed, 2298 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/preview/ThreadPreviewRouteScreen.tsx create mode 100644 apps/mobile/src/features/preview/previewAnnotation.test.ts create mode 100644 apps/mobile/src/features/preview/previewAnnotation.ts create mode 100644 apps/mobile/src/state/preview.ts create mode 100644 apps/server/src/preview/ProxyAccess.test.ts create mode 100644 apps/server/src/preview/ProxyAccess.ts create mode 100644 apps/server/src/preview/ProxyRoutes.test.ts create mode 100644 apps/server/src/preview/ProxyRoutes.ts create mode 100644 docs/internals/preview-proxy.md create mode 100644 docs/user/mobile-preview.md diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de53a37c995b..4fa377fdecd2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -114,6 +114,7 @@ "react-native-screens": "4.25.2", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", + "react-native-view-shot": "^4.0.3", "react-native-webview": "^13.16.1", "react-native-worklets": "0.8.3", "shiki": "4.2.0", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..9e2608b57462 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -25,6 +25,7 @@ import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayo import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; +import { ThreadPreviewRouteScreen } from "./features/preview/ThreadPreviewRouteScreen"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; @@ -470,6 +471,14 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/terminal`, options: SOLID_HEADER_OPTIONS, }), + ThreadPreview: createNativeStackScreen({ + screen: ThreadPreviewRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/preview`, + options: { + ...SOLID_HEADER_OPTIONS, + title: "Preview", + }, + }), ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, diff --git a/apps/mobile/src/features/preview/ThreadPreviewRouteScreen.tsx b/apps/mobile/src/features/preview/ThreadPreviewRouteScreen.tsx new file mode 100644 index 000000000000..0cc690f6c60c --- /dev/null +++ b/apps/mobile/src/features/preview/ThreadPreviewRouteScreen.tsx @@ -0,0 +1,682 @@ +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { PREVIEW_PROXY_EXIT_PATH } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Image, Platform, Pressable, ScrollView, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { captureRef } from "react-native-view-shot"; +import { WebView } from "react-native-webview"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ControlPill } from "../../components/ControlPill"; +import { LoadingStrip } from "../../components/LoadingStrip"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { previewEnvironment } from "../../state/preview"; +import { useEnvironmentQuery } from "../../state/query"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + appendReviewCommentToDraft, + useThreadDraftForThread, +} from "../../state/use-thread-composer-state"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { + addBoxMarker, + addPinMarker, + buildPreviewAnnotationAttachment, + buildPreviewAnnotationText, + removeMarker, + updateMarkerNote, + type PreviewAnnotationMarker, +} from "./previewAnnotation"; + +type ThreadPreviewRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +interface PreviewSession { + readonly server: DiscoveredLocalServer; + readonly entryUrl: string; +} + +interface CapturedShot { + readonly uri: string; + readonly width: number; + readonly height: number; +} + +/** Shown page for annotation notes: the dev server origin plus the proxied path. */ +function resolveAnnotationPageUrl( + server: DiscoveredLocalServer, + proxiedUrl: string | null, +): string { + if (proxiedUrl === null) return server.url; + try { + const parsed = new URL(proxiedUrl); + return new URL(`${parsed.pathname}${parsed.search}`, server.url).toString(); + } catch { + return server.url; + } +} + +export function ThreadPreviewRouteScreen(_props: ThreadPreviewRouteScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { selectedThread } = useThreadSelection(); + const environmentId = selectedThread?.environmentId ?? null; + const threadId = selectedThread?.id ?? null; + const preparedConnection = usePreparedConnection(environmentId); + const httpBaseUrl = Option.isSome(preparedConnection) + ? preparedConnection.value.httpBaseUrl + : null; + + const serversQuery = useEnvironmentQuery( + environmentId === null ? null : previewEnvironment.localServers({ environmentId, input: {} }), + ); + const createProxyTicket = useAtomCommand(previewEnvironment.createProxyTicket, "preview ticket"); + + const [session, setSession] = useState(null); + const [sessionError, setSessionError] = useState(null); + const [openingUrl, setOpeningUrl] = useState(null); + const [loadProgress, setLoadProgress] = useState(0); + const [loadError, setLoadError] = useState(null); + const [proxiedUrl, setProxiedUrl] = useState(null); + const [canGoBack, setCanGoBack] = useState(false); + const [capture, setCapture] = useState(null); + const webViewRef = useRef(null); + const webShotRef = useRef(null); + const sessionRef = useRef(null); + sessionRef.current = session; + const httpBaseUrlRef = useRef(null); + httpBaseUrlRef.current = httpBaseUrl; + + const endSessionOnServer = useCallback(() => { + const baseUrl = httpBaseUrlRef.current; + if (baseUrl === null) return; + // Clears the HttpOnly preview cookie; the WebView and app fetch share the + // cookie jar on Android, so this signs the whole client out of the proxy. + fetch(new URL(PREVIEW_PROXY_EXIT_PATH, baseUrl).toString()).catch(() => undefined); + }, []); + + useEffect( + () => () => { + if (sessionRef.current !== null) { + endSessionOnServer(); + } + }, + [endSessionOnServer], + ); + + const handleOpenServer = useCallback( + (server: DiscoveredLocalServer) => { + if (environmentId === null || httpBaseUrl === null) return; + setSessionError(null); + setOpeningUrl(server.url); + void createProxyTicket({ environmentId, input: { url: server.url } }).then((result) => { + setOpeningUrl(null); + if (result._tag !== "Success") { + setSessionError("The preview could not be opened. Is the dev server still running?"); + return; + } + const entryUrl = new URL(result.value.entryPath, httpBaseUrl); + entryUrl.searchParams.set("to", "/"); + setLoadError(null); + setProxiedUrl(null); + setCanGoBack(false); + setSession({ server, entryUrl: entryUrl.toString() }); + }); + }, + [createProxyTicket, environmentId, httpBaseUrl], + ); + + const handleCloseSession = useCallback(() => { + endSessionOnServer(); + setSession(null); + setCapture(null); + }, [endSessionOnServer]); + + const handleCapture = useCallback(() => { + void (async () => { + try { + const uri = await captureRef(webShotRef, { + format: "png", + quality: 1, + result: "tmpfile", + }); + const size = await new Promise<{ width: number; height: number }>((resolve, reject) => { + Image.getSize( + uri, + (width, height) => resolve({ width, height }), + (error) => reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + setCapture({ uri, ...size }); + } catch (error) { + console.warn("[preview] capture failed", error); + setLoadError("The preview could not be captured."); + } + })(); + }, []); + + const handleAnnotationDone = useCallback(() => { + setCapture(null); + navigation.goBack(); + }, [navigation]); + + if (environmentId === null || threadId === null || httpBaseUrl === null) { + return ( + + + Connecting... + + ); + } + + if (capture !== null) { + return ( + setCapture(null)} + onDone={handleAnnotationDone} + /> + ); + } + + if (session === null) { + return ( + + + Dev servers listening on this environment's machine. Opening one routes it through your + existing T3 connection - the server stays bound to that machine. + + {sessionError ? ( + + {sessionError} + + ) : null} + {serversQuery.error ? ( + + {serversQuery.error} + + ) : null} + {(serversQuery.data?.servers ?? []).map((server) => ( + handleOpenServer(server)} + > + + {server.url} + + {server.processName ?? "unknown process"} + {server.pid !== null ? ` - pid ${server.pid}` : ""} + + + {openingUrl === server.url ? ( + + ) : ( + + )} + + ))} + {serversQuery.data !== null && serversQuery.data.servers.length === 0 ? ( + + No dev servers found + + Start a dev server in a terminal on the environment, then refresh. + + + ) : null} + {serversQuery.isPending && serversQuery.data === null ? ( + + + + ) : null} + + + ); + } + + return ( + + {loadProgress > 0 && loadProgress < 1 ? : null} + {loadError ? ( + + Preview problem + {loadError} + + ) : null} + + setLoadProgress(event.nativeEvent.progress)} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={() => setLoadProgress(0)} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The dev server is unreachable."); + }} + onHttpError={(event) => { + if (event.nativeEvent.statusCode === 403 || event.nativeEvent.statusCode === 502) { + setLoadError( + event.nativeEvent.statusCode === 403 + ? "The preview session expired. Close and reopen the preview." + : "The dev server is unreachable.", + ); + } + }} + onNavigationStateChange={(navState) => { + setProxiedUrl(navState.url); + setCanGoBack(navState.canGoBack); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + + + webViewRef.current?.goBack()} + /> + webViewRef.current?.reload()} + /> + + + + + + ); +} + +function fallbackServer(url: string): DiscoveredLocalServer { + return { host: "localhost", port: 80, url, processName: null, pid: null, terminal: null }; +} + +type AnnotationTool = "pin" | "box"; + +interface DraftBox { + readonly startX: number; + readonly startY: number; + readonly currentX: number; + readonly currentY: number; +} + +function PreviewAnnotationEditor(props: { + readonly capture: CapturedShot; + readonly pageUrl: string; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly onCancel: () => void; + readonly onDone: () => void; +}) { + const insets = useSafeAreaInsets(); + const [markers, setMarkers] = useState>([]); + const [selectedMarkerId, setSelectedMarkerId] = useState(null); + const [tool, setTool] = useState("pin"); + const [canvasSize, setCanvasSize] = useState<{ width: number; height: number } | null>(null); + const [draftBox, setDraftBox] = useState(null); + const [isFlattening, setIsFlattening] = useState(false); + const [attachError, setAttachError] = useState(null); + const canvasRef = useRef(null); + const accentColor = String(useThemeColor("--color-primary")); + const { draftAttachments } = useThreadDraftForThread({ + environmentId: props.environmentId, + threadId: props.threadId, + }); + + // Fit the capture inside the available area, preserving aspect ratio. The + // canvas view is sized to exactly the displayed image so a flatten capture + // contains no letterboxing. + const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>( + null, + ); + useEffect(() => { + if (containerSize === null) return; + const scale = Math.min( + containerSize.width / props.capture.width, + containerSize.height / props.capture.height, + ); + setCanvasSize({ + width: Math.max(1, Math.floor(props.capture.width * scale)), + height: Math.max(1, Math.floor(props.capture.height * scale)), + }); + }, [containerSize, props.capture]); + + const addPinAt = useCallback((x: number, y: number, size: { width: number; height: number }) => { + setMarkers((current) => { + const next = addPinMarker(current, { x: x / size.width, y: y / size.height }); + setSelectedMarkerId(next[next.length - 1]?.id ?? null); + return next; + }); + }, []); + + const finishBox = useCallback((box: DraftBox, size: { width: number; height: number }) => { + setMarkers((current) => { + const next = addBoxMarker(current, { + startX: box.startX / size.width, + startY: box.startY / size.height, + endX: box.currentX / size.width, + endY: box.currentY / size.height, + }); + setSelectedMarkerId(next[next.length - 1]?.id ?? null); + return next; + }); + }, []); + + const gesture = useMemo(() => { + if (canvasSize === null) return Gesture.Tap().enabled(false); + const tap = Gesture.Tap() + .runOnJS(true) + .onEnd((event, success) => { + if (!success || tool !== "pin") return; + addPinAt(event.x, event.y, canvasSize); + }); + const pan = Gesture.Pan() + .runOnJS(true) + .enabled(tool === "box") + .onBegin((event) => { + setDraftBox({ startX: event.x, startY: event.y, currentX: event.x, currentY: event.y }); + }) + .onUpdate((event) => { + setDraftBox((current) => + current === null ? null : { ...current, currentX: event.x, currentY: event.y }, + ); + }) + .onEnd((event, success) => { + setDraftBox(null); + if (!success) return; + finishBox( + { + startX: event.x - event.translationX, + startY: event.y - event.translationY, + currentX: event.x, + currentY: event.y, + }, + canvasSize, + ); + }) + .onFinalize(() => setDraftBox(null)); + return Gesture.Race(pan, tap); + }, [addPinAt, canvasSize, finishBox, tool]); + + const selectedMarker = markers.find((marker) => marker.id === selectedMarkerId) ?? null; + + const handleAddToChat = useCallback(() => { + void (async () => { + setAttachError(null); + setIsFlattening(true); + try { + // Let the deselected-marker frame paint before capturing. + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + const base64 = await captureRef(canvasRef, { + format: "png", + quality: 1, + result: "base64", + }); + const result = buildPreviewAnnotationAttachment({ + base64: base64.replace(/\s/g, ""), + existingAttachmentCount: draftAttachments.length, + }); + if (!result.ok) { + setAttachError(result.error); + return; + } + appendReviewCommentToDraft({ + environmentId: props.environmentId, + threadId: props.threadId, + text: buildPreviewAnnotationText({ pageUrl: props.pageUrl, markers }), + attachments: [result.attachment], + }); + props.onDone(); + } catch (error) { + console.warn("[preview] flatten failed", error); + setAttachError("The annotated screenshot could not be created."); + } finally { + setIsFlattening(false); + } + })(); + }, [draftAttachments.length, markers, props]); + + return ( + + + { + const { width, height } = event.nativeEvent.layout; + setContainerSize({ width: Math.max(1, width - 4), height: Math.max(1, height - 4) }); + }} + > + {canvasSize === null ? ( + + ) : ( + + + + {markers.map((marker) => { + const isSelected = !isFlattening && marker.id === selectedMarkerId; + const left = marker.x * canvasSize.width; + const top = marker.y * canvasSize.height; + return marker.kind === "box" ? ( + + + + ) : ( + + + + ); + })} + {draftBox !== null ? ( + + ) : null} + + + )} + + + + {attachError ? ( + {attachError} + ) : null} + + setTool("pin")} + /> + setTool("box")} + /> + + {selectedMarker !== null ? ( + { + setMarkers((current) => removeMarker(current, selectedMarker.id)); + setSelectedMarkerId(null); + }} + /> + ) : null} + + {markers.length > 0 ? ( + + + {markers.map((marker) => ( + setSelectedMarkerId(marker.id)} + > + {marker.index} + + ))} + + + ) : ( + + Tap to drop a numbered pin, or switch to Box and drag over an area. + + )} + {selectedMarker !== null ? ( + + setMarkers((current) => updateMarkerNote(current, selectedMarker.id, note)) + } + className="rounded-2xl border border-border bg-card px-4 py-2.5 font-sans text-base" + /> + ) : null} + + + + + + + + + ); +} + +function MarkerBadge(props: { + readonly index: number; + readonly color: string; + readonly large?: boolean; +}) { + const size = props.large ? 30 : 26; + return ( + + {props.index} + + ); +} diff --git a/apps/mobile/src/features/preview/previewAnnotation.test.ts b/apps/mobile/src/features/preview/previewAnnotation.test.ts new file mode 100644 index 000000000000..55fdd104bad3 --- /dev/null +++ b/apps/mobile/src/features/preview/previewAnnotation.test.ts @@ -0,0 +1,99 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +// expo-crypto reaches for native modules in a node test; ids only need to be unique. +vi.mock("../../lib/uuid", () => { + let counter = 0; + return { uuidv4: () => `uuid-${++counter}` }; +}); + +import { + addBoxMarker, + addPinMarker, + buildPreviewAnnotationAttachment, + buildPreviewAnnotationText, + removeMarker, + updateMarkerNote, +} from "./previewAnnotation"; + +describe("preview annotation markers", () => { + it("numbers markers contiguously and renumbers after removal", () => { + let markers = addPinMarker([], { x: 0.1, y: 0.2 }); + markers = addBoxMarker(markers, { startX: 0.5, startY: 0.5, endX: 0.7, endY: 0.6 }); + markers = addPinMarker(markers, { x: 0.9, y: 0.9 }); + expect(markers.map((marker) => marker.index)).toEqual([1, 2, 3]); + + markers = removeMarker(markers, markers[1]!.id); + expect(markers.map((marker) => marker.index)).toEqual([1, 2]); + expect(markers.map((marker) => marker.kind)).toEqual(["pin", "pin"]); + }); + + it("normalizes boxes dragged from any corner and clamps to the image", () => { + const markers = addBoxMarker([], { startX: 0.8, startY: 0.9, endX: 0.2, endY: 0.3 }); + expect(markers[0]).toMatchObject({ x: 0.2, y: 0.3 }); + expect(markers[0]!.width).toBeCloseTo(0.6); + expect(markers[0]!.height).toBeCloseTo(0.6); + + const clamped = addPinMarker([], { x: -0.5, y: 1.5 }); + expect(clamped[0]).toMatchObject({ x: 0, y: 1 }); + }); + + it("keeps notes attached to their marker", () => { + let markers = addPinMarker([], { x: 0.1, y: 0.1 }); + markers = addPinMarker(markers, { x: 0.2, y: 0.2 }); + markers = updateMarkerNote(markers, markers[1]!.id, "align this button"); + expect(markers[0]!.note).toBe(""); + expect(markers[1]!.note).toBe("align this button"); + }); + + it("builds numbered note text matching the badges", () => { + let markers = addPinMarker([], { x: 0.1, y: 0.1 }); + markers = updateMarkerNote(markers, markers[0]!.id, "wrong color"); + markers = addBoxMarker(markers, { startX: 0.2, startY: 0.2, endX: 0.4, endY: 0.4 }); + const text = buildPreviewAnnotationText({ + pageUrl: "http://localhost:5173/", + markers, + }); + expect(text).toBe( + [ + "Annotated preview screenshot of http://localhost:5173/ (attached):", + "1. wrong color", + "2. (see marker in screenshot)", + ].join("\n"), + ); + }); +}); + +describe("buildPreviewAnnotationAttachment", () => { + const base64Png = Buffer.from("fake png bytes").toString("base64"); + + it("wraps a capture in the composer attachment shape", () => { + const result = buildPreviewAnnotationAttachment({ + base64: base64Png, + existingAttachmentCount: 0, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.attachment).toMatchObject({ + type: "image", + name: "preview-annotation.png", + mimeType: "image/png", + }); + expect(result.attachment.dataUrl.startsWith("data:image/png;base64,")).toBe(true); + expect(result.attachment.sizeBytes).toBeGreaterThan(0); + }); + + it("enforces the existing attachment count limit", () => { + const result = buildPreviewAnnotationAttachment({ + base64: base64Png, + existingAttachmentCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + }); + expect(result).toMatchObject({ ok: false }); + }); + + it("rejects empty captures", () => { + expect( + buildPreviewAnnotationAttachment({ base64: "", existingAttachmentCount: 0 }), + ).toMatchObject({ ok: false }); + }); +}); diff --git a/apps/mobile/src/features/preview/previewAnnotation.ts b/apps/mobile/src/features/preview/previewAnnotation.ts new file mode 100644 index 000000000000..cae85a75da7c --- /dev/null +++ b/apps/mobile/src/features/preview/previewAnnotation.ts @@ -0,0 +1,159 @@ +/** + * Pure model behind the preview screenshot annotation editor: numbered + * markers and boxes over a captured image, each carrying a text note. + * Coordinates are normalized (0..1) against the displayed image so the + * flattened capture and the note text never depend on device pixel sizes. + */ +import { + isProviderSendTurnSupportedImageMimeType, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; + +import { estimateBase64ByteSize } from "../../lib/base64"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { uuidv4 } from "../../lib/uuid"; + +export interface PreviewAnnotationMarker { + readonly id: string; + /** 1-based badge number, always contiguous with the marker's list position. */ + readonly index: number; + readonly kind: "pin" | "box"; + /** Normalized top-left corner (0..1) of the marker within the image. */ + readonly x: number; + readonly y: number; + /** Normalized size (0..1). Pins have zero size. */ + readonly width: number; + readonly height: number; + readonly note: string; +} + +const clampUnit = (value: number) => Math.min(1, Math.max(0, value)); + +const renumber = ( + markers: ReadonlyArray, +): ReadonlyArray => + markers.map((marker, position) => ({ ...marker, index: position + 1 })); + +export function addPinMarker( + markers: ReadonlyArray, + point: { readonly x: number; readonly y: number }, +): ReadonlyArray { + return renumber([ + ...markers, + { + id: uuidv4(), + index: markers.length + 1, + kind: "pin", + x: clampUnit(point.x), + y: clampUnit(point.y), + width: 0, + height: 0, + note: "", + }, + ]); +} + +/** Boxes can be dragged from any corner; store the normalized top-left rect. */ +export function addBoxMarker( + markers: ReadonlyArray, + rect: { + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + }, +): ReadonlyArray { + const left = clampUnit(Math.min(rect.startX, rect.endX)); + const top = clampUnit(Math.min(rect.startY, rect.endY)); + const right = clampUnit(Math.max(rect.startX, rect.endX)); + const bottom = clampUnit(Math.max(rect.startY, rect.endY)); + return renumber([ + ...markers, + { + id: uuidv4(), + index: markers.length + 1, + kind: "box", + x: left, + y: top, + width: right - left, + height: bottom - top, + note: "", + }, + ]); +} + +export function removeMarker( + markers: ReadonlyArray, + id: string, +): ReadonlyArray { + return renumber(markers.filter((marker) => marker.id !== id)); +} + +export function updateMarkerNote( + markers: ReadonlyArray, + id: string, + note: string, +): ReadonlyArray { + return markers.map((marker) => (marker.id === id ? { ...marker, note } : marker)); +} + +/** + * The text appended to the composer draft alongside the flattened screenshot. + * Numbers match the badges baked into the image. + */ +export function buildPreviewAnnotationText(input: { + readonly pageUrl: string; + readonly markers: ReadonlyArray; +}): string { + const lines = [`Annotated preview screenshot of ${input.pageUrl} (attached):`]; + for (const marker of input.markers) { + const note = marker.note.trim(); + lines.push(`${marker.index}. ${note.length > 0 ? note : "(see marker in screenshot)"}`); + } + return lines.join("\n"); +} + +export type PreviewAnnotationAttachmentResult = + | { readonly ok: true; readonly attachment: DraftComposerImageAttachment } + | { readonly ok: false; readonly error: string }; + +/** + * Wrap a flattened PNG capture in the standard composer attachment shape, + * enforcing the same count and size limits as every other image attachment. + */ +export function buildPreviewAnnotationAttachment(input: { + readonly base64: string; + readonly existingAttachmentCount: number; +}): PreviewAnnotationAttachmentResult { + if (input.existingAttachmentCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + return { + ok: false, + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + }; + } + const mimeType = "image/png"; + if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { + return { ok: false, error: "Screenshots must be PNG images." }; + } + const sizeBytes = estimateBase64ByteSize(input.base64); + if (sizeBytes <= 0) { + return { ok: false, error: "The screenshot capture came back empty." }; + } + if (sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return { ok: false, error: "The screenshot is too large to attach." }; + } + const dataUrl = `data:${mimeType};base64,${input.base64}`; + return { + ok: true, + attachment: { + id: uuidv4(), + type: "image", + name: "preview-annotation.png", + mimeType, + sizeBytes, + dataUrl, + previewUri: dataUrl, + }, + }; +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cad1cab8e602..c704b14bf268 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -35,6 +35,7 @@ import { useRemoteConnectionStatus, useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; +import { useEnvironmentServerConfig } from "../../state/entities"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; @@ -518,6 +519,21 @@ function ThreadRouteContent( [navigation, selectedThread, selectedThreadProject?.workspaceRoot], ); + const selectedThreadServerConfig = useEnvironmentServerConfig( + selectedThread?.environmentId ?? null, + ); + const previewProxySupported = + selectedThreadServerConfig?.environment.capabilities.previewProxy === true; + const handleOpenPreview = useCallback(() => { + if (!selectedThread) { + return; + } + void navigation.navigate("ThreadPreview", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }); + }, [navigation, selectedThread]); + const handleOpenNewTerminal = useCallback(() => { terminalDebugLog("terminal-menu:open-new", { hasThread: Boolean(selectedThread), @@ -703,6 +719,13 @@ function ThreadRouteContent( onPress: () => handleOpenTerminal(null), }); } + if (previewProxySupported) { + actions.push({ + accessibilityLabel: "Open dev server preview", + icon: "safari", + onPress: handleOpenPreview, + }); + } actions.push({ accessibilityLabel: "Open git controls", icon: "point.topleft.down.curvedto.point.bottomright.up", @@ -721,7 +744,9 @@ function ThreadRouteContent( handleOpenFilesInspector, handleOpenTerminal, handleOpenGitInspector, + handleOpenPreview, handleToggleInspector, + previewProxySupported, props.onReturnToThread, selectedThreadCwd, selectedThreadProject?.workspaceRoot, diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts new file mode 100644 index 000000000000..af15f38ab773 --- /dev/null +++ b/apps/mobile/src/state/preview.ts @@ -0,0 +1,5 @@ +import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb0563421408..53a9d3a0f84f 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -499,7 +499,7 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; const DPOP_AUTHORIZATION_PREFIX = "DPoP "; -const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; +export const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { const leftCanManage = left.scopes.includes(AuthAccessWriteScope); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..f215fa73cdb0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -114,6 +114,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, [WS_METHODS.previewList]: AuthOrchestrationReadScope, + [WS_METHODS.previewListLocalServers]: AuthOrchestrationReadScope, + // Minting a ticket opens content access to a host-local server, which is an + // operation on the environment, not a read of orchestration state. + [WS_METHODS.previewCreateProxyTicket]: AuthOrchestrationOperateScope, [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..8c19a195e771 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + previewProxy: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/preview/ProxyAccess.test.ts b/apps/server/src/preview/ProxyAccess.test.ts new file mode 100644 index 000000000000..22933ef92e93 --- /dev/null +++ b/apps/server/src/preview/ProxyAccess.test.ts @@ -0,0 +1,141 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PortScanner from "./PortScanner.ts"; +import { + issueProxyTicket, + PREVIEW_PROXY_ENTRY_PREFIX, + redeemEntryTicket, + verifySessionCookie, +} from "./ProxyAccess.ts"; + +const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-preview-proxy-test-", +}); + +const environmentService = (id: string) => + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make(id)), + getDescriptor: Effect.die("unused"), + }); + +const discoveredServer = { + host: "127.0.0.1", + port: 5173, + url: "http://127.0.0.1:5173/", + processName: null, + pid: null, + terminal: null, +}; + +const portDiscoveryLayer = Layer.succeed( + PortScanner.PortDiscovery, + PortScanner.PortDiscovery.of({ + scan: () => Effect.succeed([discoveredServer]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), +); + +const testLayer = Layer.mergeAll( + Layer.succeed(ServerEnvironment.ServerEnvironment, environmentService("environment-proxy-test")), + portDiscoveryLayer, + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + TestClock.layer(), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const entryToken = (entryPath: string) => entryPath.slice(`${PREVIEW_PROXY_ENTRY_PREFIX}/`.length); + +describe("PreviewProxyAccess", () => { + it.effect("rejects malformed, non-loopback, and undiscovered targets", () => + Effect.gen(function* () { + expect(yield* issueProxyTicket({ url: "not a url" }).pipe(Effect.flip)).toMatchObject({ + reason: "invalid-url", + }); + expect( + yield* issueProxyTicket({ url: "https://127.0.0.1:5173/" }).pipe(Effect.flip), + ).toMatchObject({ reason: "invalid-url" }); + expect( + yield* issueProxyTicket({ url: "http://example.com:5173/" }).pipe(Effect.flip), + ).toMatchObject({ reason: "not-local" }); + expect( + yield* issueProxyTicket({ url: "http://127.0.0.1:9999/" }).pipe(Effect.flip), + ).toMatchObject({ reason: "not-discovered" }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues tickets that redeem once into a verifiable session cookie", () => + Effect.gen(function* () { + const ticket = yield* issueProxyTicket({ url: "http://127.0.0.1:5173/" }); + expect(ticket.entryPath.startsWith(`${PREVIEW_PROXY_ENTRY_PREFIX}/`)).toBe(true); + + const redemption = yield* redeemEntryTicket(entryToken(ticket.entryPath)); + expect(redemption.ok).toBe(true); + if (!redemption.ok) return; + expect(redemption.claims).toMatchObject({ host: "127.0.0.1", port: 5173 }); + + const claims = yield* verifySessionCookie(redemption.cookieValue); + expect(claims).toMatchObject({ kind: "session", host: "127.0.0.1", port: 5173 }); + + // Second redemption of the same entry ticket fails. + expect(yield* redeemEntryTicket(entryToken(ticket.entryPath))).toEqual({ + ok: false, + reason: "reused", + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects expired entry tickets and expired session cookies", () => + Effect.gen(function* () { + const ticket = yield* issueProxyTicket({ url: "http://127.0.0.1:5173/" }); + yield* TestClock.adjust(Duration.minutes(3)); + expect(yield* redeemEntryTicket(entryToken(ticket.entryPath))).toEqual({ + ok: false, + reason: "expired", + }); + + const freshTicket = yield* issueProxyTicket({ url: "http://127.0.0.1:5173/" }); + const redemption = yield* redeemEntryTicket(entryToken(freshTicket.entryPath)); + expect(redemption.ok).toBe(true); + if (!redemption.ok) return; + yield* TestClock.adjust(Duration.hours(13)); + expect(yield* verifySessionCookie(redemption.cookieValue)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered tokens and tickets from another environment", () => + Effect.gen(function* () { + const ticket = yield* issueProxyTicket({ url: "http://127.0.0.1:5173/" }); + const token = entryToken(ticket.entryPath); + + expect(yield* redeemEntryTicket(`${token}tampered`)).toEqual({ + ok: false, + reason: "malformed", + }); + expect(yield* redeemEntryTicket("garbage")).toEqual({ ok: false, reason: "malformed" }); + + // Same signing key, different environment id: explicit cross-environment rejection. + expect( + yield* redeemEntryTicket(token).pipe( + Effect.provideService( + ServerEnvironment.ServerEnvironment, + environmentService("environment-someone-else"), + ), + ), + ).toEqual({ ok: false, reason: "cross-environment" }); + + // The genuine environment can still redeem it afterwards. + expect((yield* redeemEntryTicket(token)).ok).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/preview/ProxyAccess.ts b/apps/server/src/preview/ProxyAccess.ts new file mode 100644 index 000000000000..3408077a9cfd --- /dev/null +++ b/apps/server/src/preview/ProxyAccess.ts @@ -0,0 +1,237 @@ +/** + * Preview proxy access - tickets and cookie sessions for the remote dev-server + * preview. + * + * A paired client (the mobile WebView) cannot attach bearer headers to + * subresource requests, so access works like asset URLs: the server mints a + * signed, single-use entry ticket over the authenticated WebSocket, the client + * navigates to the entry path, and the server exchanges the ticket for an + * HttpOnly session cookie that authorizes the proxy for the rest of the + * browsing session. Claims pin the environment id and the validated + * host-local port, so a ticket cannot be replayed against another environment + * or redirected to an arbitrary port. + */ +import { PREVIEW_PROXY_EXIT_PATH, PreviewProxyTicketError } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PortScanner from "./PortScanner.ts"; + +export const PREVIEW_PROXY_ROUTE_PREFIX = "/api/preview"; +export const PREVIEW_PROXY_ENTRY_PREFIX = `${PREVIEW_PROXY_ROUTE_PREFIX}/enter`; +export { PREVIEW_PROXY_EXIT_PATH }; +export const PREVIEW_PROXY_COOKIE_NAME = "t3_preview_proxy"; + +const SIGNING_SECRET_NAME = "preview-proxy-signing-key"; +const ENTRY_TICKET_TTL_MS = 2 * 60 * 1000; +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; + +const ProxyClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("entry"), + environmentId: Schema.String, + host: Schema.String, + port: Schema.Number, + ticketId: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("session"), + environmentId: Schema.String, + host: Schema.String, + port: Schema.Number, + expiresAt: Schema.Number, + }), +]); +type ProxyClaims = typeof ProxyClaimsSchema.Type; +export type ProxySessionClaims = Extract; + +const ProxyClaimsJson = Schema.fromJsonString(ProxyClaimsSchema); +const decodeProxyClaims = Schema.decodeUnknownOption(ProxyClaimsJson); +const encodeProxyClaims = Schema.encodeSync(ProxyClaimsJson); + +/** + * Entry tickets are single use. Redeemed ticket ids are held in memory until + * they would have expired anyway; a server restart forgets them, but a + * restart also rotates nothing the ticket could still be replayed against + * inside its two-minute window beyond what a fresh mint would grant. + */ +const consumedEntryTickets = new Map(); + +function pruneConsumedTickets(now: number): void { + for (const [ticketId, expiresAt] of consumedEntryTickets) { + if (expiresAt <= now) { + consumedEntryTickets.delete(ticketId); + } + } +} + +const loadSigningSecret = ServerSecretStore.ServerSecretStore.pipe( + Effect.flatMap((secretStore) => secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32)), +); + +function decodeToken(token: string, secret: Uint8Array): ProxyClaims | null { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) return null; + try { + return Option.getOrNull(decodeProxyClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +function encodeToken(claims: ProxyClaims, secret: Uint8Array): string { + const encodedPayload = base64UrlEncode(encodeProxyClaims(claims)); + return `${encodedPayload}.${signPayload(encodedPayload, secret)}`; +} + +function parseUrl(raw: string): URL | null { + try { + return new URL(raw); + } catch { + return null; + } +} + +export const issueProxyTicket = Effect.fn("PreviewProxyAccess.issueProxyTicket")(function* (input: { + readonly url: string; +}) { + const parsed = parseUrl(input.url); + if (parsed === null) { + return yield* new PreviewProxyTicketError({ reason: "invalid-url" }); + } + if (parsed.protocol !== "http:") { + return yield* new PreviewProxyTicketError({ reason: "invalid-url" }); + } + if (!isLoopbackHost(parsed.hostname)) { + return yield* new PreviewProxyTicketError({ reason: "not-local" }); + } + const port = Number(parsed.port || "80"); + + // Only ports the scanner classified as host-local web servers are + // proxyable; the ticket pins the discovered host so the proxy never + // connects anywhere the environment did not already expose locally. + const portDiscovery = yield* PortScanner.PortDiscovery; + const servers = yield* portDiscovery.scan([input.url]); + const server = servers.find( + (candidate) => candidate.port === port && isLoopbackHost(candidate.host), + ); + if (!server) { + return yield* new PreviewProxyTicketError({ reason: "not-discovered" }); + } + + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* serverEnvironment.getEnvironmentId; + const crypto = yield* Crypto.Crypto; + const ticketId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => new PreviewProxyTicketError({ reason: "issuance-failed" })), + ); + const now = yield* Clock.currentTimeMillis; + const expiresAt = now + ENTRY_TICKET_TTL_MS; + const secret = yield* loadSigningSecret.pipe( + Effect.mapError(() => new PreviewProxyTicketError({ reason: "issuance-failed" })), + ); + const token = encodeToken( + { + version: 1, + kind: "entry", + environmentId, + host: server.host, + port: server.port, + ticketId, + expiresAt, + }, + secret, + ); + return { entryPath: `${PREVIEW_PROXY_ENTRY_PREFIX}/${token}`, expiresAt }; +}); + +export type EntryRedemption = + | { + readonly ok: true; + readonly cookieValue: string; + readonly claims: ProxySessionClaims; + } + | { + readonly ok: false; + readonly reason: "malformed" | "expired" | "reused" | "cross-environment"; + }; + +/** Exchange a single-use entry ticket for a signed session cookie value. */ +export const redeemEntryTicket = Effect.fn("PreviewProxyAccess.redeemEntryTicket")(function* ( + token: string, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the preview proxy signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret) return { ok: false, reason: "malformed" } satisfies EntryRedemption; + const claims = decodeToken(token, secret); + if (!claims || claims.kind !== "entry") { + return { ok: false, reason: "malformed" } satisfies EntryRedemption; + } + const now = yield* Clock.currentTimeMillis; + pruneConsumedTickets(now); + if (claims.expiresAt <= now) { + return { ok: false, reason: "expired" } satisfies EntryRedemption; + } + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* serverEnvironment.getEnvironmentId; + if (claims.environmentId !== environmentId) { + return { ok: false, reason: "cross-environment" } satisfies EntryRedemption; + } + if (consumedEntryTickets.has(claims.ticketId)) { + return { ok: false, reason: "reused" } satisfies EntryRedemption; + } + consumedEntryTickets.set(claims.ticketId, claims.expiresAt); + + const sessionClaims: ProxySessionClaims = { + version: 1, + kind: "session", + environmentId: claims.environmentId, + host: claims.host, + port: claims.port, + expiresAt: now + SESSION_TTL_MS, + }; + return { + ok: true, + cookieValue: encodeToken(sessionClaims, secret), + claims: sessionClaims, + } satisfies EntryRedemption; +}); + +/** Validate a preview session cookie. Returns the claims or null. */ +export const verifySessionCookie = Effect.fn("PreviewProxyAccess.verifySessionCookie")(function* ( + cookieValue: string, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the preview proxy signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret) return null; + const claims = decodeToken(cookieValue, secret); + if (!claims || claims.kind !== "session") return null; + const now = yield* Clock.currentTimeMillis; + const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId; + if (claims.expiresAt <= now || claims.environmentId !== environmentId) return null; + return claims; +}); diff --git a/apps/server/src/preview/ProxyRoutes.test.ts b/apps/server/src/preview/ProxyRoutes.test.ts new file mode 100644 index 000000000000..7d80ca516f5f --- /dev/null +++ b/apps/server/src/preview/ProxyRoutes.test.ts @@ -0,0 +1,268 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Http from "node:http"; +import type * as Net from "node:net"; +import { FetchHttpClient, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PortScanner from "./PortScanner.ts"; +import { + issueProxyTicket, + PREVIEW_PROXY_COOKIE_NAME, + PREVIEW_PROXY_EXIT_PATH, +} from "./ProxyAccess.ts"; +import { + filterForwardedCookieHeader, + filterForwardedRequestHeaders, + isForwardableSetCookie, + previewProxyEntryRouteLayer, + previewProxyExitRouteLayer, + previewProxyMiddlewareLayer, + shouldBypassPreviewProxy, +} from "./ProxyRoutes.ts"; + +describe("shouldBypassPreviewProxy", () => { + it("bypasses requests presenting T3 credentials", () => { + expect( + shouldBypassPreviewProxy({ + path: "/", + hasAuthorizationHeader: true, + hasWsTicketParam: false, + }), + ).toBe(true); + expect( + shouldBypassPreviewProxy({ + path: "/ws", + hasAuthorizationHeader: false, + hasWsTicketParam: true, + }), + ).toBe(true); + }); + + it("bypasses reserved T3 paths and proxies everything else", () => { + for (const path of [ + "/api/preview", + "/api/preview/enter/token", + "/api/preview/exit", + "/api/assets/token/file.png", + "/.well-known/t3/environment", + ]) { + expect( + shouldBypassPreviewProxy({ + path, + hasAuthorizationHeader: false, + hasWsTicketParam: false, + }), + ).toBe(true); + } + for (const path of ["/", "/src/main.tsx", "/@vite/client", "/api/data", "/ws", "/index.html"]) { + expect( + shouldBypassPreviewProxy({ + path, + hasAuthorizationHeader: false, + hasWsTicketParam: false, + }), + ).toBe(false); + } + }); +}); + +describe("header hygiene", () => { + it("strips T3 cookies but forwards the dev server's own", () => { + expect( + filterForwardedCookieHeader( + `${PREVIEW_PROXY_COOKIE_NAME}=abc; t3_session_1234_ff=def; myapp=1`, + ), + ).toBe("myapp=1"); + expect(filterForwardedCookieHeader(`${PREVIEW_PROXY_COOKIE_NAME}=abc`)).toBeNull(); + }); + + it("keeps upstream Set-Cookie values away from T3 cookie names", () => { + expect(isForwardableSetCookie("myapp=1; Path=/")).toBe(true); + expect(isForwardableSetCookie("t3_session=stolen; Path=/")).toBe(false); + expect(isForwardableSetCookie(`${PREVIEW_PROXY_COOKIE_NAME}=forged`)).toBe(false); + }); + + it("never forwards credential or hop-by-hop request headers", () => { + const forwarded = filterForwardedRequestHeaders({ + authorization: "Bearer secret", + dpop: "proof", + host: "t3.example", + connection: "keep-alive", + "accept-encoding": "gzip", + "content-type": "application/json", + "x-custom": "yes", + cookie: `${PREVIEW_PROXY_COOKIE_NAME}=abc; theirs=1`, + }); + expect(forwarded).toEqual({ + "content-type": "application/json", + "x-custom": "yes", + cookie: "theirs=1", + }); + }); +}); + +const environmentLayer = Layer.succeed( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-proxy-routes-test")), + getDescriptor: Effect.die("unused"), + }), +); + +// The upstream port is only known once the echo server binds; the discovery +// mock reads it through this box. +let upstreamPort = 0; + +const portDiscoveryLayer = Layer.succeed( + PortScanner.PortDiscovery, + PortScanner.PortDiscovery.of({ + scan: () => + Effect.sync(() => [ + { + host: "127.0.0.1", + port: upstreamPort, + url: `http://127.0.0.1:${upstreamPort}/`, + processName: null, + pid: null, + terminal: null, + }, + ]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), +); + +const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-preview-proxy-routes-test-", +}); + +const depsLayer = Layer.mergeAll( + environmentLayer, + portDiscoveryLayer, + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeUpstreamEchoServer = Effect.acquireRelease( + Effect.callback((resume) => { + const server = Http.createServer((request, response) => { + response.writeHead(200, { "content-type": "application/json", "x-upstream": "echo" }); + response.end(JSON.stringify({ url: request.url, headers: request.headers })); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), +); + +// deps (secret store, environment, discovery) are deliberately NOT provided +// here: they resolve from the test context so the router and the in-test +// ticket mint share one signing key. +const routerLayer = Layer.mergeAll( + previewProxyEntryRouteLayer, + previewProxyExitRouteLayer, + HttpRouter.add("GET", "*", Effect.succeed(HttpServerResponse.text("t3-app"))), +).pipe(Layer.provide(previewProxyMiddlewareLayer)); + +const fetchManual = (url: string, init?: RequestInit) => + Effect.tryPromise(() => globalThis.fetch(url, { redirect: "manual", ...init })); + +describe("preview proxy routes", () => { + it.effect("routes the whole origin through the proxy for cookie-holding requests", () => + Effect.scoped( + Effect.gen(function* () { + const upstream = yield* makeUpstreamEchoServer; + upstreamPort = (upstream.address() as Net.AddressInfo).port; + + yield* HttpRouter.serve(routerLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag !== "TcpAddress") throw new Error("expected tcp address"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const ticket = yield* issueProxyTicket({ + url: `http://127.0.0.1:${upstreamPort}/`, + }); + + // Entering redeems the ticket, sets the cookie, and redirects into the origin. + const entry = yield* fetchManual(`${baseUrl}${ticket.entryPath}?to=/dashboard`); + expect(entry.status).toBe(302); + expect(entry.headers.get("location")).toBe("/dashboard"); + const setCookie = entry.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain(`${PREVIEW_PROXY_COOKIE_NAME}=`); + expect(setCookie.toLowerCase()).toContain("httponly"); + const cookiePair = setCookie.split(";")[0]!; + + // A reused entry ticket fails. + const reused = yield* fetchManual(`${baseUrl}${ticket.entryPath}`); + expect(reused.status).toBe(403); + + // Root-relative documents, assets, and API calls proxy through, with + // T3 credentials stripped before they reach the dev server. + const proxied = yield* fetchManual(`${baseUrl}/api/data?x=1`, { + headers: { + cookie: `${cookiePair}; t3_session=topsecret`, + "x-custom": "yes", + }, + }); + expect(proxied.status).toBe(200); + expect(proxied.headers.get("x-upstream")).toBe("echo"); + const echoed = (yield* Effect.tryPromise(() => proxied.json())) as { + url: string; + headers: Record; + }; + expect(echoed.url).toBe("/api/data?x=1"); + expect(echoed.headers["x-custom"]).toBe("yes"); + expect(echoed.headers["authorization"]).toBeUndefined(); + expect(echoed.headers["cookie"]).toBeUndefined(); + expect(echoed.headers["host"]).toBe(`127.0.0.1:${upstreamPort}`); + + // Requests presenting T3 credentials bypass the proxy entirely. + const bypassed = yield* fetchManual(`${baseUrl}/anything`, { + headers: { cookie: cookiePair, authorization: "Bearer whatever" }, + }); + expect(yield* Effect.tryPromise(() => bypassed.text())).toBe("t3-app"); + + // A tampered cookie fails closed with a clear error, not the T3 app. + const tampered = yield* fetchManual(`${baseUrl}/`, { + headers: { cookie: `${PREVIEW_PROXY_COOKIE_NAME}=forged` }, + }); + expect(tampered.status).toBe(403); + + // Exit clears the cookie. + const exit = yield* fetchManual(`${baseUrl}${PREVIEW_PROXY_EXIT_PATH}`, { + headers: { cookie: cookiePair }, + }); + expect(exit.status).toBe(204); + expect(exit.headers.get("set-cookie") ?? "").toContain(`${PREVIEW_PROXY_COOKIE_NAME}=;`); + + // Without the cookie the origin serves T3 as usual. + const plain = yield* fetchManual(`${baseUrl}/anything`); + expect(yield* Effect.tryPromise(() => plain.text())).toBe("t3-app"); + }), + ).pipe( + // A plain server layer (not layerTest): the middleware resolves the + // ambient HttpClient for upstream requests, which must be a real fetch + // client, not layerTest's server-relative client. + Effect.provide( + Layer.mergeAll( + NodeHttpServer.layer(() => Http.createServer(), { port: 0 }), + FetchHttpClient.layer, + depsLayer, + ), + ), + ), + ); +}); diff --git a/apps/server/src/preview/ProxyRoutes.ts b/apps/server/src/preview/ProxyRoutes.ts new file mode 100644 index 000000000000..9623ebff0161 --- /dev/null +++ b/apps/server/src/preview/ProxyRoutes.ts @@ -0,0 +1,362 @@ +/** + * Preview proxy routes - the HTTP surface of the remote dev-server preview. + * + * Three pieces: + * + * - An entry route that exchanges a single-use ticket (minted over the + * authenticated WebSocket) for an HttpOnly session cookie and redirects + * into the proxied origin. + * - An exit route that clears the cookie when the client closes the preview. + * - A global middleware that, for requests carrying a valid session cookie, + * proxies the whole origin - documents, root-relative assets, fetch + * requests, and WebSocket upgrades (HMR) - to the validated host-local + * port. Requests that present T3 credentials (Authorization header or a + * wsTicket query param) and a small set of reserved T3 paths always bypass + * the proxy, because on Android the app's own fetches share the WebView + * cookie jar. + * + * T3 credentials never reach the dev server: proxied requests are exactly the + * ones without credential headers, and T3 cookies are stripped from the + * forwarded Cookie header. + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { + Headers, + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import * as Cookies from "effect/unstable/http/Cookies"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { WEBSOCKET_TICKET_QUERY_PARAM } from "../auth/EnvironmentAuth.ts"; +import { + PREVIEW_PROXY_COOKIE_NAME, + PREVIEW_PROXY_ENTRY_PREFIX, + PREVIEW_PROXY_EXIT_PATH, + PREVIEW_PROXY_ROUTE_PREFIX, + redeemEntryTicket, + verifySessionCookie, + type ProxySessionClaims, +} from "./ProxyAccess.ts"; + +/** + * Paths that must keep working while a preview cookie is set. `/api/preview` + * is the proxy's own control surface, `/api/assets` carries signed asset URLs + * the app renders without credentials, and `/.well-known/t3` is the + * unauthenticated connection probe. + */ +const PREVIEW_PROXY_BYPASS_PREFIXES = [ + `${PREVIEW_PROXY_ROUTE_PREFIX}/`, + "/api/assets/", + "/.well-known/t3/", +] as const; + +/** T3 cookies stay on the T3 origin; everything else forwards to the dev server. */ +const isReservedCookieName = (name: string) => + name === PREVIEW_PROXY_COOKIE_NAME || name.startsWith("t3_session"); + +/** + * Pure routing decision: true when the request must be handled by T3 itself + * even though a preview cookie may be present. + */ +export function shouldBypassPreviewProxy(input: { + readonly path: string; + readonly hasAuthorizationHeader: boolean; + readonly hasWsTicketParam: boolean; +}): boolean { + if (input.hasAuthorizationHeader || input.hasWsTicketParam) return true; + if (input.path === PREVIEW_PROXY_ROUTE_PREFIX) return true; + return PREVIEW_PROXY_BYPASS_PREFIXES.some((prefix) => input.path.startsWith(prefix)); +} + +const HOP_BY_HOP_REQUEST_HEADERS = new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "te", + "trailer", + "expect", + "proxy-authorization", + "proxy-connection", + // Credentials must never reach the dev server. + "authorization", + "dpop", + // fetch negotiates its own encoding and decompresses transparently. + "accept-encoding", + // The body is re-streamed; fetch recomputes framing. + "content-length", +]); + +const HOP_BY_HOP_RESPONSE_HEADERS = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "te", + "trailer", + // fetch already decompressed the body; the compression middleware + // re-encodes for the client if it wants to. + "content-encoding", + "content-length", +]); + +/** Strip T3 cookies from a Cookie header, preserving the dev server's own. */ +export function filterForwardedCookieHeader(cookieHeader: string): string | null { + const kept = cookieHeader + .split(";") + .map((pair) => pair.trim()) + .filter((pair) => { + if (pair.length === 0) return false; + const name = pair.slice(0, pair.indexOf("=") === -1 ? pair.length : pair.indexOf("=")); + return !isReservedCookieName(name.trim()); + }); + return kept.length > 0 ? kept.join("; ") : null; +} + +/** Drop upstream Set-Cookie values that would clobber T3's own cookies. */ +export function isForwardableSetCookie(value: string): boolean { + const separator = value.indexOf("="); + if (separator <= 0) return false; + return !isReservedCookieName(value.slice(0, separator).trim()); +} + +export function filterForwardedRequestHeaders( + headers: Readonly>, +): Record { + const forwarded: Record = {}; + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase(); + if (HOP_BY_HOP_REQUEST_HEADERS.has(lower)) continue; + if (lower === "cookie") { + const filtered = filterForwardedCookieHeader(value); + if (filtered !== null) forwarded[lower] = filtered; + continue; + } + forwarded[lower] = value; + } + return forwarded; +} + +function upstreamAuthority(claims: ProxySessionClaims): string { + const host = claims.host.includes(":") ? `[${claims.host}]` : claims.host; + return `${host}:${claims.port}`; +} + +const previewCookieOptions = { + httpOnly: true, + path: "/", + sameSite: "lax", +} as const; + +const setPreviewCookie = (value: string, expiresAtEpochMillis: number) => + Effect.fromResult( + Cookies.set(Cookies.empty, PREVIEW_PROXY_COOKIE_NAME, value, { + ...previewCookieOptions, + expires: DateTime.toDate(DateTime.makeUnsafe(expiresAtEpochMillis)), + }), + ); + +const clearPreviewCookieResponse = (response: HttpServerResponse.HttpServerResponse) => + setPreviewCookie("", 0).pipe( + Effect.map((cookies) => HttpServerResponse.mergeCookies(response, cookies)), + Effect.orElseSucceed(() => response), + ); + +const failurePage = (status: number, message: string) => + HttpServerResponse.text(message, { status, headers: { "cache-control": "no-store" } }); + +function resolveEntryRedirectTarget(search: URLSearchParams): string { + const target = search.get("to"); + if (!target || !target.startsWith("/") || target.startsWith("//")) return "/"; + return target; +} + +/** GET /api/preview/enter/?to=/path - redeem a ticket, set the cookie, enter the origin. */ +export const previewProxyEntryRouteLayer = HttpRouter.add( + "GET", + `${PREVIEW_PROXY_ENTRY_PREFIX}/*`, + 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 token = url.value.pathname.slice(`${PREVIEW_PROXY_ENTRY_PREFIX}/`.length); + const redemption = yield* redeemEntryTicket(token); + if (!redemption.ok) { + return yield* clearPreviewCookieResponse( + failurePage(403, `Preview ticket rejected (${redemption.reason}).`), + ); + } + const cookies = yield* setPreviewCookie( + redemption.cookieValue, + redemption.claims.expiresAt, + ).pipe(Effect.orElseSucceed(() => Cookies.empty)); + return HttpServerResponse.mergeCookies( + HttpServerResponse.redirect(resolveEntryRedirectTarget(url.value.searchParams), { + status: 302, + headers: { "cache-control": "no-store" }, + }), + cookies, + ); + }), +); + +/** GET /api/preview/exit - clear the preview session cookie. */ +export const previewProxyExitRouteLayer = HttpRouter.add( + "GET", + PREVIEW_PROXY_EXIT_PATH, + clearPreviewCookieResponse(HttpServerResponse.empty({ status: 204 })), +); + +const proxyHttpRequest = ( + claims: ProxySessionClaims, + request: HttpServerRequest.HttpServerRequest, + url: URL, +) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const target = `http://${upstreamAuthority(claims)}${url.pathname}${url.search}`; + const headers = filterForwardedRequestHeaders(request.headers); + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const upstreamRequest = HttpClientRequest.make(request.method)(target, { headers }).pipe( + hasBody ? HttpClientRequest.bodyStream(request.stream) : (self) => self, + ); + const response = yield* httpClient.execute(upstreamRequest).pipe( + // Redirects belong to the WebView, not the proxy. + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + + const responseHeaders: Record> = {}; + for (const [name, value] of Object.entries(response.headers)) { + const lower = name.toLowerCase(); + if (HOP_BY_HOP_RESPONSE_HEADERS.has(lower)) continue; + if (lower === "set-cookie") { + const values = (Array.isArray(value) ? value : [value]).filter(isForwardableSetCookie); + if (values.length > 0) responseHeaders[lower] = values; + continue; + } + responseHeaders[lower] = value; + } + + if (response.status === 204 || response.status === 304) { + return HttpServerResponse.empty({ + status: response.status, + headers: Headers.fromInput(responseHeaders), + }); + } + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers: Headers.fromInput(responseHeaders), + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Preview proxy upstream request failed", { cause }).pipe( + Effect.as( + failurePage( + 502, + `The previewed dev server is unreachable (${upstreamAuthority(claims)}).`, + ), + ), + ), + ), + ); + +const globalWebSocketConstructor: (typeof Socket.WebSocketConstructor)["Service"] = ( + url, + protocols, +) => new globalThis.WebSocket(url, protocols); + +const proxyWebSocketUpgrade = ( + claims: ProxySessionClaims, + request: HttpServerRequest.HttpServerRequest, + url: URL, +) => + Effect.gen(function* () { + const target = `ws://${upstreamAuthority(claims)}${url.pathname}${url.search}`; + const protocolHeader = request.headers["sec-websocket-protocol"]; + const protocols = + typeof protocolHeader === "string" + ? protocolHeader + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + : undefined; + const clientSocket = yield* request.upgrade; + const upstreamSocket = yield* Socket.makeWebSocket(target, { + ...(protocols && protocols.length > 0 ? { protocols } : {}), + openTimeout: "5 seconds", + }); + yield* Effect.scoped( + Effect.gen(function* () { + const writeToClient = yield* clientSocket.writer; + const writeToUpstream = yield* upstreamSocket.writer; + // Frames pump in both directions until either side closes; the race + // interrupts the surviving side and scope closure shuts both sockets. + yield* Effect.raceFirst( + clientSocket.runRaw((data) => writeToUpstream(data)), + upstreamSocket.runRaw((data) => writeToClient(data)), + ); + }), + ).pipe(Effect.catchIf(Socket.isSocketError, () => Effect.void)); + return HttpServerResponse.empty(); + }).pipe( + Effect.provideService(Socket.WebSocketConstructor, globalWebSocketConstructor), + Effect.catchCause((cause) => + Effect.logDebug("Preview proxy websocket relay failed", { cause }).pipe( + Effect.as(failurePage(502, "The previewed dev server websocket is unreachable.")), + ), + ), + ); + +const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => + request.headers["upgrade"]?.toLowerCase() === "websocket"; + +/** + * Global middleware: requests carrying a valid preview session cookie are + * proxied to the pinned host-local port; everything else falls through to the + * regular router. + */ +export const previewProxyMiddlewareLayer = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const cookieValue = request.cookies[PREVIEW_PROXY_COOKIE_NAME]; + if (cookieValue === undefined) { + return yield* httpEffect; + } + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return yield* httpEffect; + } + if ( + shouldBypassPreviewProxy({ + path: url.value.pathname, + hasAuthorizationHeader: typeof request.headers["authorization"] === "string", + hasWsTicketParam: url.value.searchParams.has(WEBSOCKET_TICKET_QUERY_PARAM), + }) + ) { + return yield* httpEffect; + } + const claims = yield* verifySessionCookie(cookieValue); + if (claims === null) { + // Expired or foreign cookie: clear it so the next request heals, and + // report the failure instead of silently showing the T3 app. + return yield* clearPreviewCookieResponse( + failurePage(403, "Preview session expired. Close and reopen the preview."), + ); + } + return yield* isWebSocketUpgrade(request) + ? proxyWebSocketUpgrade(claims, request, url.value) + : proxyHttpRequest(claims, request, url.value); + }), + { global: true }, +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..9dd4b7108c7a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,6 +19,11 @@ import { httpCompressionLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + previewProxyEntryRouteLayer, + previewProxyExitRouteLayer, + previewProxyMiddlewareLayer, +} from "./preview/ProxyRoutes.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -456,11 +461,17 @@ export const makeRoutesLayer = Layer.mergeAll( ), otlpTracesProxyRouteLayer, assetRouteLayer, + previewProxyEntryRouteLayer, + previewProxyExitRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( + // Runs before auth-free routes resolve: requests holding a preview session + // cookie are proxied to the pinned dev server unless they carry T3 + // credentials or target reserved T3 paths. + Layer.provide(previewProxyMiddlewareLayer), // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..55bfb2334ef6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -91,6 +91,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import * as PreviewProxyAccess from "./preview/ProxyAccess.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; @@ -2200,6 +2201,22 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { "rpc.aggregate": "preview", }), + [WS_METHODS.previewListLocalServers]: (input) => + observeRpcEffect( + WS_METHODS.previewListLocalServers, + Effect.gen(function* () { + const servers = yield* portDiscovery.scan(input.configuredUrls ?? []); + const scannedAt = DateTime.formatIso(yield* DateTime.now); + return { servers, scannedAt, configuredUrlProbing: true as const }; + }), + { "rpc.aggregate": "preview" }, + ), + [WS_METHODS.previewCreateProxyTicket]: (input) => + observeRpcEffect( + WS_METHODS.previewCreateProxyTicket, + PreviewProxyAccess.issueProxyTicket(input), + { "rpc.aggregate": "preview" }, + ), [WS_METHODS.previewReportStatus]: (input) => observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", diff --git a/docs/README.md b/docs/README.md index 622d81064387..375706a232f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) +- [Mobile dev server preview](./user/mobile-preview.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) @@ -34,6 +35,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) - [Environment auth](./internals/environment-auth.md) +- [Preview proxy](./internals/preview-proxy.md) - [T3 Connect](./internals/t3-connect.md) - [CI gates](./internals/ci.md) - [Engineering work artifacts](./internals/work-artifacts.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..37cd695d016e 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi - [Orchestration](#orchestration) - [Provider runtime](#provider-runtime) - [Checkpointing](#checkpointing) +- [Preview](#preview) ## Concepts @@ -140,6 +141,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. +### Preview + +#### Preview proxy + +The server-side reverse proxy that lets a paired remote client (the mobile WebView) browse a +loopback dev server through the environment origin. Tickets and cookie sessions live in +[ProxyAccess.ts][25]; the routes and origin-claiming middleware live in [ProxyRoutes.ts][26]. See +[the preview proxy doc][27]. + +#### Preview ticket + +A single-use, HMAC-signed entry credential minted over the authenticated WebSocket by +`preview.createProxyTicket`. Redeeming it sets the HttpOnly preview session cookie and redirects +into the proxied dev server. Implementation in [ProxyAccess.ts][25]. + ## Practical Shortcuts - If you see `requested`, think "intent recorded". @@ -179,3 +195,6 @@ The file patch and changed-file summary for one turn. It is usually computed in [22]: ../../apps/server/src/checkpointing/Utils.ts [23]: ../../apps/server/src/checkpointing/Diffs.ts [24]: ./overview.md +[25]: ../../apps/server/src/preview/ProxyAccess.ts +[26]: ../../apps/server/src/preview/ProxyRoutes.ts +[27]: ./preview-proxy.md diff --git a/docs/internals/preview-proxy.md b/docs/internals/preview-proxy.md new file mode 100644 index 000000000000..12906e09df17 --- /dev/null +++ b/docs/internals/preview-proxy.md @@ -0,0 +1,58 @@ +# Preview proxy + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +The preview proxy lets a paired remote client (today: the mobile WebView) browse a dev server that +listens only on the environment's loopback interface, through the environment's own HTTP origin. +It reuses whatever transport already reaches the environment — LAN, Tailscale, or T3 Connect — so +the dev server never needs a public URL. This is a separate mechanism from the desktop preview +panel, which renders in an Electron `` on the client itself. + +## Access model + +A WebView cannot attach bearer headers to subresource requests, so access works like signed asset +URLs plus a cookie session: + +1. The client calls `preview.listLocalServers` (unary sibling of + `subscribeDiscoveredLocalServers`, both backed by the [port scanner][2]) and picks a target. +2. The client calls `preview.createProxyTicket` over the authenticated WebSocket. The server + validates the URL against the discovered loopback servers and returns a single-use entry path + with a two-minute expiry. Ticket claims are HMAC-signed ([`ProxyAccess`][1]) and pin the + environment id, host, and port. +3. The WebView navigates to the entry path. The server redeems the ticket — expired, reused, + malformed, or cross-environment tickets fail — sets an HttpOnly session cookie scoped to `/`, + and 302-redirects into the proxied origin. +4. From then on, a global middleware ([`ProxyRoutes`][3]) proxies every request carrying a valid + session cookie — documents, root-relative assets, fetch calls, and WebSocket upgrades (HMR) — + to the pinned loopback port. `GET /api/preview/exit` clears the cookie. + +## Why the whole origin + +Dev servers assume they own their origin: `/src/main.tsx`, `/@vite/client`, and HMR sockets are +all root-relative. Rewriting HTML is fragile, so instead the session cookie claims the entire +environment origin for the browsing context that holds it. + +On Android, React Native's fetch and the WebView share one cookie jar, so the middleware must not +capture the app's own traffic. Requests bypass the proxy when they present T3 credentials (an +`Authorization` header or a `wsTicket` query param) or target a reserved prefix (`/api/preview/`, +`/api/assets/`, `/.well-known/t3/`). Consequently a previewed dev server cannot itself use those +exact paths, and T3 credentials never reach it: credentialed requests are never proxied, and T3 +cookies are stripped from forwarded `Cookie` headers. + +## Version skew + +Servers advertise the `previewProxy` capability on the environment descriptor; clients hide the +preview entry point when it is absent. The mobile picker and ticket RPCs follow the standard +capability gate (`=== true`). + +## Known limits + +- Web dev (Vite single-origin) is not wired for browser use of the proxy: root-relative proxied + paths would resolve against the Vite origin, not the server. The feature targets the mobile + WebView, which talks to the server origin directly. +- Entry tickets survive server restarts within their two-minute window (signing key is persisted, + the redeemed-ticket set is in memory). + +[1]: ../../apps/server/src/preview/ProxyAccess.ts +[2]: ../../apps/server/src/preview/PortScanner.ts +[3]: ../../apps/server/src/preview/ProxyRoutes.ts diff --git a/docs/user/mobile-preview.md b/docs/user/mobile-preview.md new file mode 100644 index 000000000000..1fb5a6bdfc58 --- /dev/null +++ b/docs/user/mobile-preview.md @@ -0,0 +1,35 @@ +# Mobile dev server preview + +T3 Code Mobile can open a dev server running on your environment's machine — a Vite app on +`localhost:5173`, a Next.js app on `localhost:3000` — without exposing it to the internet. The page +loads through your existing T3 connection, whether that is your local network, Tailscale, or +T3 Connect. The dev server stays bound to the machine it runs on and never needs a public URL. + +To open a preview: + +1. Open a thread on the environment running the dev server. +2. Tap the **preview** button in the thread header. +3. Pick a dev server from the list. Servers started in thread terminals are discovered + automatically. + +The page renders live: root-relative assets, API calls, and hot reload all work. Use the toolbar to +go back, reload, capture a screenshot, or close the preview. + +## Annotate and send to the agent + +1. Tap **Capture** to snapshot the current viewport. +2. Tap to drop numbered pins, or switch to **Box** and drag to outline an area. +3. Write a note for each marker. +4. Tap **Add to chat**. + +The flattened screenshot and your numbered notes are added to the message draft. Nothing sends +until you do — edit the message, remove the attachment, or add more images first. **Cancel** +discards the annotation and leaves your draft unchanged. + +## Notes and limits + +- One preview is open at a time. Opening another dev server replaces the current preview session. +- Preview access uses a short-lived ticket from the connected environment and works only for that + environment's own localhost servers. +- Agent browser automation is unaffected: agents keep driving the preview browser on the host + machine, not on your phone. diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 86ca157047ba..3e4eea5289c7 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -45,6 +45,13 @@ export function createPreviewEnvironmentAtoms( // unmounted projects stop contributing probe candidates on the server. idleTtlMs: 0, }), + // One-shot discovery snapshot for surfaces (the mobile preview picker) + // that don't hold the discovery subscription open. + localServers: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:preview:local-servers", + tag: WS_METHODS.previewListLocalServers, + staleTimeMs: 3_000, + }), automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:preview:automation-requests", tag: WS_METHODS.previewAutomationConnect, @@ -83,6 +90,15 @@ export function createPreviewEnvironmentAtoms( scheduler: lifecycleScheduler, concurrency: lifecycleConcurrency, }), + createProxyTicket: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:create-proxy-ticket", + tag: WS_METHODS.previewCreateProxyTicket, + scheduler: lifecycleScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }: { environmentId: string }) => environmentId, + }, + }), reportStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:report-status", tag: WS_METHODS.previewReportStatus, diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3a4324625a00..ddbd0cc14f88 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.pullRequests, ).toBe(true); }); + + it("treats a missing preview-proxy capability as unsupported under version skew", () => { + expect(decodeDescriptor(descriptor).capabilities.previewProxy).toBeUndefined(); + }); + + it("preserves an advertised preview-proxy capability", () => { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, previewProxy: true }, + }).capabilities.previewProxy, + ).toBe(true); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..eb8dc2fb468c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -80,6 +80,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server can mint preview proxy tickets and proxy localhost dev servers + to remote clients. Absent on older servers, so clients hide the mobile + preview entry point instead of sending the requests. */ + previewProxy: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..a017b4403255 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -314,6 +314,62 @@ export const DiscoveredLocalServerList = Schema.Struct({ }); export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; +/** + * Fixed preview-proxy control path shared by the server routes and the mobile + * client: a GET here clears the preview session cookie. The entry path is not + * a constant because clients receive it from `preview.createProxyTicket`. + */ +export const PREVIEW_PROXY_EXIT_PATH = "/api/preview/exit"; + +/** + * Unary snapshot of the discovered localhost dev servers, for clients that + * want a one-shot list (the mobile preview picker) instead of holding the + * `subscribeDiscoveredLocalServers` stream open. + */ +export const PreviewListLocalServersInput = Schema.Struct({ + configuredUrls: Schema.optional(ConfiguredLocalServerUrls), +}); +export type PreviewListLocalServersInput = typeof PreviewListLocalServersInput.Type; + +export const PreviewProxyTicketInput = Schema.Struct({ + /** URL of a discovered localhost dev server, as returned by discovery. */ + url: Url, +}); +export type PreviewProxyTicketInput = typeof PreviewProxyTicketInput.Type; + +export const PreviewProxyTicketResult = Schema.Struct({ + /** + * Relative path on the environment origin that a client navigates to (in a + * WebView) to start the proxied preview session. Single use: entering + * exchanges the ticket for an HttpOnly session cookie and redirects into + * the proxied dev server. + */ + entryPath: TrimmedNonEmptyString, + /** Epoch milliseconds after which the entry ticket is no longer accepted. */ + expiresAt: Schema.Number, +}); +export type PreviewProxyTicketResult = typeof PreviewProxyTicketResult.Type; + +export class PreviewProxyTicketError extends Schema.TaggedErrorClass()( + "PreviewProxyTicketError", + { + reason: Schema.Literals(["invalid-url", "not-local", "not-discovered", "issuance-failed"]), + }, +) { + override get message() { + switch (this.reason) { + case "invalid-url": + return "The preview target is not a valid URL."; + case "not-local": + return "The preview target is not a loopback address on this environment."; + case "not-discovered": + return "The preview target does not match a discovered local dev server."; + case "issuance-failed": + return "The preview ticket could not be issued."; + } + } +} + export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( "PreviewSessionLookupError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..94daa8e0472d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -136,9 +136,13 @@ import { PreviewError, PreviewEvent, PreviewListInput, + PreviewListLocalServersInput, PreviewListResult, PreviewNavigateInput, PreviewOpenInput, + PreviewProxyTicketError, + PreviewProxyTicketInput, + PreviewProxyTicketResult, PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, @@ -246,6 +250,8 @@ export const WS_METHODS = { previewRefresh: "preview.refresh", previewClose: "preview.close", previewList: "preview.list", + previewListLocalServers: "preview.listLocalServers", + previewCreateProxyTicket: "preview.createProxyTicket", previewReportStatus: "preview.reportStatus", previewAutomationConnect: "previewAutomation.connect", previewAutomationRespond: "previewAutomation.respond", @@ -827,6 +833,18 @@ export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { error: EnvironmentAuthorizationError, }); +export const WsPreviewListLocalServersRpc = Rpc.make(WS_METHODS.previewListLocalServers, { + payload: PreviewListLocalServersInput, + success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, +}); + +export const WsPreviewCreateProxyTicketRpc = Rpc.make(WS_METHODS.previewCreateProxyTicket, { + payload: PreviewProxyTicketInput, + success: PreviewProxyTicketResult, + error: Schema.Union([PreviewProxyTicketError, EnvironmentAuthorizationError]), +}); + export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), @@ -1063,6 +1081,8 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewRefreshRpc, WsPreviewCloseRpc, WsPreviewListRpc, + WsPreviewListLocalServersRpc, + WsPreviewCreateProxyTicketRpc, WsPreviewReportStatusRpc, WsPreviewAutomationConnectRpc, WsPreviewAutomationRespondRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..985359b81297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,6 +409,9 @@ importers: react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-view-shot: + specifier: ^4.0.3 + version: 4.0.3(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -5184,10 +5187,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} @@ -5583,6 +5588,10 @@ packages: base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -6002,6 +6011,9 @@ packages: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -7224,6 +7236,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -9058,6 +9074,12 @@ packages: peerDependencies: react-native: '*' + react-native-view-shot@4.0.3: + resolution: {integrity: sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-webview@13.16.1: resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} peerDependencies: @@ -9741,6 +9763,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -10110,6 +10135,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -15923,6 +15951,8 @@ snapshots: base-64@1.0.0: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.33: {} @@ -16341,6 +16371,10 @@ snapshots: crypto-js@4.2.0: {} + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -17923,6 +17957,11 @@ snapshots: html-void-elements@3.0.0: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -20015,6 +20054,12 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-view-shot@4.0.3(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + html2canvas: 1.4.1 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 @@ -21044,6 +21089,10 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + throat@5.0.0: {} timestring@6.0.0: {} @@ -21362,6 +21411,10 @@ snapshots: utils-merge@1.0.1: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + uuid@14.0.1: {} uuid@7.0.3: {}