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: {}