| author | |
| committer | |
| log | 677d1527c3d96b90b2294d9a0aed7d8c8874b29b |
| tree | 81b453897829e0c8997a59bba80f244bbb9994e3 |
| parent | c138163c8cc39d4a98b2d3d796bcef183013bac5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>53 files changed, 4902 insertions(+), 166 deletions(-)
apps/mobile/src/features/files/AttachmentFileScreen.tsx+11-2| ... | ... | @@ -24,6 +24,7 @@ import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 24 | 24 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 25 | 25 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 26 | 26 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 27 | import { HtmlRenderWebView } from "../threads/HtmlRenderWebView"; | |
| 27 | 28 | |
| 28 | 29 | /** |
| 29 | 30 | * Both the thread stack and the new-task sheet stack register this screen, so a chip in a |
| ... | ... | @@ -38,6 +39,8 @@ export type AttachmentFileRouteParams = { |
| 38 | 39 | readonly sizeBytes: string; |
| 39 | 40 | /** Present for a draft attachment, which may still live only on this device. */ |
| 40 | 41 | readonly draftKey?: string; |
| 42 | /** Present for a page an agent published with `html_render`, which takes the app theme. */ | |
| 43 | readonly htmlRender?: "1"; | |
| 41 | 44 | }; |
| 42 | 45 | |
| 43 | 46 | type AttachmentFileScreenProps = StaticScreenProps<AttachmentFileRouteParams>; |
| ... | ... | @@ -54,6 +57,7 @@ function AttachmentDocumentBody(props: { |
| 54 | 57 | readonly document: ReturnType<typeof useAttachmentDocument>; |
| 55 | 58 | readonly name: string; |
| 56 | 59 | readonly environmentId: EnvironmentId | null; |
| 60 | readonly htmlRender: boolean; | |
| 57 | 61 | readonly nativeViewer: "pending" | "open" | "unavailable" | null; |
| 58 | 62 | readonly nativeError: string | null; |
| 59 | 63 | readonly onOpenNative: () => void; |
| ... | ... | @@ -141,7 +145,11 @@ function AttachmentDocumentBody(props: { |
| 141 | 145 | return <AudioFilePreview key={document.revision} uri={document.uri} onRetry={document.retry} />; |
| 142 | 146 | } |
| 143 | 147 | if (document.kind === "html") { |
| 144 | return <WorkspaceFileWebPreview uri={document.uri} />; | |
| 148 | return props.htmlRender ? ( | |
| 149 | <HtmlRenderWebView key={document.uri} uri={document.uri} title={props.name} nested={false} /> | |
| 150 | ) : ( | |
| 151 | <WorkspaceFileWebPreview uri={document.uri} /> | |
| 152 | ); | |
| 145 | 153 | } |
| 146 | 154 | return ( |
| 147 | 155 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| ... | ... | @@ -339,7 +347,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 339 | 347 | }, |
| 340 | 348 | [menuActions], |
| 341 | 349 | ); |
| 342 | const subtitle = `${draftKey ? "Draft attachment" : "Attachment"} · ${formatAttachmentSize(sizeBytes)}`; | |
| 350 | const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}`; | |
| 343 | 351 | |
| 344 | 352 | return ( |
| 345 | 353 | <View className="flex-1 bg-sheet"> |
| ... | ... | @@ -405,6 +413,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 405 | 413 | document={document} |
| 406 | 414 | name={params.name} |
| 407 | 415 | environmentId={environmentId} |
| 416 | htmlRender={params.htmlRender === "1"} | |
| 408 | 417 | nativeViewer={nativeViewer} |
| 409 | 418 | nativeError={nativeError} |
| 410 | 419 | onOpenNative={() => { |
apps/mobile/src/features/threads/HtmlRenderWebView.tsx created+289| ... | ... | @@ -0,0 +1,289 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | htmlRenderFileName, | |
| 5 | htmlRenderFrameHeight, | |
| 6 | htmlRenderThemeFragment, | |
| 7 | htmlRenderThemeMessage, | |
| 8 | type HtmlRenderReference, | |
| 9 | type HtmlRenderTheme, | |
| 10 | } from "@t3tools/shared/htmlRender"; | |
| 11 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 12 | import { ActivityIndicator, Platform, Pressable, View, type ColorValue } from "react-native"; | |
| 13 | import { WebView, type WebViewMessageEvent } from "react-native-webview"; | |
| 14 | ||
| 15 | import { SymbolView } from "../../components/AppSymbol"; | |
| 16 | import { AppText as Text } from "../../components/AppText"; | |
| 17 | import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; | |
| 18 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | |
| 19 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; | |
| 20 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 21 | ||
| 22 | const ROW_BOTTOM_MARGIN = 8; | |
| 23 | const FULL_SCREEN_GUTTER = 16; | |
| 24 | ||
| 25 | /** A render row is its frame's height plus spacing; the page's content never sizes it. */ | |
| 26 | export function htmlRenderRowHeight(frameHeight: number) { | |
| 27 | return frameHeight + ROW_BOTTOM_MARGIN; | |
| 28 | } | |
| 29 | ||
| 30 | function useHtmlRenderTheme() { | |
| 31 | const { themeId, themeAppearance, themeVariables, systemColorsActive } = | |
| 32 | useAppearancePreferences(); | |
| 33 | return useMemo( | |
| 34 | () => | |
| 35 | mobileHtmlRenderTheme({ | |
| 36 | themeId, | |
| 37 | appearance: themeAppearance, | |
| 38 | variables: themeVariables, | |
| 39 | systemColors: systemColorsActive, | |
| 40 | platform: Platform.OS, | |
| 41 | }), | |
| 42 | [themeId, themeAppearance, themeVariables, systemColorsActive], | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) { | |
| 47 | view?.injectJavaScript( | |
| 48 | `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`, | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow"; | |
| 53 | ||
| 54 | // Reports whether the page overflows its frame, so a feed row only takes scroll | |
| 55 | // gestures from a page that can use them. | |
| 56 | const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var o=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1||Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},overflow:o}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`; | |
| 57 | ||
| 58 | function readOverflowMessage(data: string) { | |
| 59 | try { | |
| 60 | const message: unknown = JSON.parse(data); | |
| 61 | return typeof message === "object" && | |
| 62 | message !== null && | |
| 63 | "type" in message && | |
| 64 | message.type === OVERFLOW_MESSAGE_TYPE && | |
| 65 | "overflow" in message && | |
| 66 | typeof message.overflow === "boolean" | |
| 67 | ? message.overflow | |
| 68 | : null; | |
| 69 | } catch { | |
| 70 | return null; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | const withoutFragment = (url: string) => url.split("#", 1)[0]; | |
| 75 | ||
| 76 | /** | |
| 77 | * An agent's HTML page, themed before first paint and kept in step with the app theme. | |
| 78 | * The page may move within its own document; links leave for the browser. | |
| 79 | */ | |
| 80 | export function HtmlRenderWebView(props: { | |
| 81 | readonly uri: string; | |
| 82 | readonly title: string; | |
| 83 | /** Inside the feed, the page takes scroll gestures only when it overflows its frame. */ | |
| 84 | readonly nested: boolean; | |
| 85 | readonly onLoadError?: () => void; | |
| 86 | }) { | |
| 87 | const theme = useHtmlRenderTheme(); | |
| 88 | const [initialTheme] = useState(theme); | |
| 89 | const [generation, setGeneration] = useState(0); | |
| 90 | const [loaded, setLoaded] = useState(false); | |
| 91 | const [overflows, setOverflows] = useState(false); | |
| 92 | const webView = useRef<WebView<object>>(null); | |
| 93 | const crashes = useRef(0); | |
| 94 | // The theme the loaded document shows; null until it loads. | |
| 95 | const shownTheme = useRef<HtmlRenderTheme | null>(null); | |
| 96 | const source = useMemo( | |
| 97 | () => ({ uri: props.uri + htmlRenderThemeFragment(initialTheme) }), | |
| 98 | [props.uri, initialTheme], | |
| 99 | ); | |
| 100 | useEffect(() => { | |
| 101 | if (shownTheme.current === null || shownTheme.current === theme) return; | |
| 102 | shownTheme.current = theme; | |
| 103 | postTheme(webView.current, theme); | |
| 104 | }, [theme]); | |
| 105 | const restart = () => { | |
| 106 | // A page that keeps crashing its web process is not reloaded forever. | |
| 107 | crashes.current += 1; | |
| 108 | if (crashes.current > 1) { | |
| 109 | props.onLoadError?.(); | |
| 110 | return; | |
| 111 | } | |
| 112 | shownTheme.current = null; | |
| 113 | setLoaded(false); | |
| 114 | setOverflows(false); | |
| 115 | setGeneration((value) => value + 1); | |
| 116 | }; | |
| 117 | const scrollable = !props.nested || overflows; | |
| 118 | // Pages have no horizontal padding of their own, so full screen adds the | |
| 119 | // feed's gutter in the page's background color. | |
| 120 | return ( | |
| 121 | <View | |
| 122 | style={ | |
| 123 | props.nested | |
| 124 | ? { flex: 1 } | |
| 125 | : { | |
| 126 | flex: 1, | |
| 127 | paddingHorizontal: FULL_SCREEN_GUTTER, | |
| 128 | backgroundColor: theme.variables["--background"], | |
| 129 | } | |
| 130 | } | |
| 131 | > | |
| 132 | <WebView<object> | |
| 133 | key={generation} | |
| 134 | ref={webView} | |
| 135 | source={source} | |
| 136 | accessibilityLabel={props.title} | |
| 137 | style={{ flex: 1, backgroundColor: "transparent" }} | |
| 138 | allowsInlineMediaPlayback | |
| 139 | automaticallyAdjustContentInsets={!props.nested} | |
| 140 | bounces={!props.nested} | |
| 141 | showsVerticalScrollIndicator={!props.nested} | |
| 142 | showsHorizontalScrollIndicator={!props.nested} | |
| 143 | scrollEnabled={scrollable} | |
| 144 | nestedScrollEnabled={props.nested && overflows} | |
| 145 | overScrollMode={props.nested ? "never" : "always"} | |
| 146 | // Only the page itself loads here; other top-frame navigations are | |
| 147 | // dropped. A link the reader taps opens as a new window, which the | |
| 148 | // platform allows only from a tap, and goes to the browser. | |
| 149 | onShouldStartLoadWithRequest={(request) => | |
| 150 | request.isTopFrame === false || | |
| 151 | withoutFragment(request.url) === withoutFragment(props.uri) | |
| 152 | } | |
| 153 | onOpenWindow={(event) => { | |
| 154 | const url = event.nativeEvent.targetUrl; | |
| 155 | if (/^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render"); | |
| 156 | }} | |
| 157 | onLoadEnd={() => { | |
| 158 | setLoaded(true); | |
| 159 | shownTheme.current = theme; | |
| 160 | if (theme !== initialTheme) postTheme(webView.current, theme); | |
| 161 | }} | |
| 162 | onError={props.onLoadError} | |
| 163 | onHttpError={props.onLoadError} | |
| 164 | onContentProcessDidTerminate={restart} | |
| 165 | onRenderProcessGone={restart} | |
| 166 | {...(props.nested | |
| 167 | ? { | |
| 168 | injectedJavaScript: OVERFLOW_SCRIPT, | |
| 169 | onMessage: (event: WebViewMessageEvent) => { | |
| 170 | const overflow = readOverflowMessage(event.nativeEvent.data); | |
| 171 | if (overflow !== null) setOverflows(overflow); | |
| 172 | }, | |
| 173 | } | |
| 174 | : {})} | |
| 175 | /> | |
| 176 | {loaded ? null : ( | |
| 177 | <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> | |
| 178 | <ActivityIndicator /> | |
| 179 | </View> | |
| 180 | )} | |
| 181 | </View> | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | /** A completed `html_render` call in the thread feed: the page itself, at a fixed height. */ | |
| 186 | export function ThreadHtmlRender(props: { | |
| 187 | readonly environmentId: EnvironmentId; | |
| 188 | readonly threadId: ThreadId; | |
| 189 | readonly render: HtmlRenderReference; | |
| 190 | /** The feed's content width; the frame's height follows the page's measured height there. */ | |
| 191 | readonly frameWidth: number; | |
| 192 | readonly iconColor: ColorValue; | |
| 193 | }) { | |
| 194 | const navigation = useNavigation(); | |
| 195 | const { attachmentId, title } = props.render; | |
| 196 | const height = htmlRenderFrameHeight(props.render, props.frameWidth); | |
| 197 | const fileName = htmlRenderFileName(title); | |
| 198 | const resource = useMemo( | |
| 199 | () => ({ | |
| 200 | _tag: "attachment" as const, | |
| 201 | attachmentId, | |
| 202 | fileName, | |
| 203 | mimeType: "text/html", | |
| 204 | disposition: "inline" as const, | |
| 205 | }), | |
| 206 | [attachmentId, fileName], | |
| 207 | ); | |
| 208 | const asset = useAssetUrlState(props.environmentId, resource); | |
| 209 | const refresh = useRefreshAssetUrl(props.environmentId, resource); | |
| 210 | // Signed URLs are re-minted periodically; following them would reload the page. | |
| 211 | const [uri, setUri] = useState<string | null>(null); | |
| 212 | if (uri === null && asset._tag === "Success") setUri(asset.url); | |
| 213 | const [failed, setFailed] = useState(false); | |
| 214 | // A failed load retries once with a fresh URL, or remounts on the same one, | |
| 215 | // since the failure may have been the connection rather than the URL. | |
| 216 | const [attempt, setAttempt] = useState(0); | |
| 217 | const retried = useRef(false); | |
| 218 | const handleLoadError = () => { | |
| 219 | if (retried.current) { | |
| 220 | setFailed(true); | |
| 221 | return; | |
| 222 | } | |
| 223 | retried.current = true; | |
| 224 | void refresh().then((next) => { | |
| 225 | if (next === null) setFailed(true); | |
| 226 | else if (next !== uri) setUri(next); | |
| 227 | else setAttempt((value) => value + 1); | |
| 228 | }); | |
| 229 | }; | |
| 230 | ||
| 231 | return ( | |
| 232 | <View style={{ marginBottom: ROW_BOTTOM_MARGIN }}> | |
| 233 | <View style={{ height }}> | |
| 234 | {uri !== null && !failed ? ( | |
| 235 | <HtmlRenderWebView | |
| 236 | key={`${uri}:${attempt}`} | |
| 237 | uri={uri} | |
| 238 | title={title} | |
| 239 | nested | |
| 240 | onLoadError={handleLoadError} | |
| 241 | /> | |
| 242 | ) : failed || asset._tag === "Failure" ? ( | |
| 243 | <Pressable | |
| 244 | accessibilityRole="button" | |
| 245 | accessibilityLabel={`Reload ${title}`} | |
| 246 | className="flex-1 items-center justify-center" | |
| 247 | onPress={() => { | |
| 248 | retried.current = false; | |
| 249 | setFailed(false); | |
| 250 | if (uri === null) void refresh().then((next) => next !== null && setUri(next)); | |
| 251 | }} | |
| 252 | > | |
| 253 | <Text className="text-sm text-foreground-muted">Page unavailable</Text> | |
| 254 | </Pressable> | |
| 255 | ) : ( | |
| 256 | <View className="flex-1 items-center justify-center"> | |
| 257 | <ActivityIndicator /> | |
| 258 | </View> | |
| 259 | )} | |
| 260 | {uri !== null && !failed ? ( | |
| 261 | <Pressable | |
| 262 | accessibilityRole="button" | |
| 263 | accessibilityLabel={`Open ${title}`} | |
| 264 | hitSlop={8} | |
| 265 | className="absolute right-1.5 top-1.5 h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-surface/80" | |
| 266 | onPress={() => | |
| 267 | navigation.navigate("ThreadAttachment", { | |
| 268 | environmentId: String(props.environmentId), | |
| 269 | threadId: String(props.threadId), | |
| 270 | attachmentId, | |
| 271 | name: fileName, | |
| 272 | mimeType: "text/html", | |
| 273 | sizeBytes: "0", | |
| 274 | htmlRender: "1", | |
| 275 | }) | |
| 276 | } | |
| 277 | > | |
| 278 | <SymbolView | |
| 279 | name="arrow.up.left.and.arrow.down.right" | |
| 280 | size={12} | |
| 281 | tintColor={props.iconColor} | |
| 282 | type="monochrome" | |
| 283 | /> | |
| 284 | </Pressable> | |
| 285 | ) : null} | |
| 286 | </View> | |
| 287 | </View> | |
| 288 | ); | |
| 289 | } |
apps/mobile/src/features/threads/ThreadFeed.tsx+34-1| ... | ... | @@ -183,6 +183,8 @@ import { |
| 183 | 183 | import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; |
| 184 | 184 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 185 | 185 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; |
| 186 | import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; | |
| 187 | import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; | |
| 186 | 188 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 187 | 189 | import { |
| 188 | 190 | assetEnvironment, |
| ... | ... | @@ -1536,6 +1538,8 @@ function renderFeedEntry( |
| 1536 | 1538 | readonly userBubbleMaxWidth: number; |
| 1537 | 1539 | /** Width assistant markdown lays out in, so images can size their frame before layout. */ |
| 1538 | 1540 | readonly markdownContentWidth: number; |
| 1541 | /** Width full-bleed rows (HTML renders) lay out in. */ | |
| 1542 | readonly contentWidth: number; | |
| 1539 | 1543 | readonly threadTitle: string; |
| 1540 | 1544 | }, |
| 1541 | 1545 | ) { |
| ... | ... | @@ -1574,6 +1578,18 @@ function renderFeedEntry( |
| 1574 | 1578 | return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />; |
| 1575 | 1579 | } |
| 1576 | 1580 | |
| 1581 | if (entry.type === "html-render") { | |
| 1582 | return ( | |
| 1583 | <ThreadHtmlRender | |
| 1584 | environmentId={props.environmentId} | |
| 1585 | threadId={props.threadId} | |
| 1586 | render={entry.render} | |
| 1587 | frameWidth={props.contentWidth} | |
| 1588 | iconColor={iconSubtleColor} | |
| 1589 | /> | |
| 1590 | ); | |
| 1591 | } | |
| 1592 | ||
| 1577 | 1593 | if (entry.type === "work-toggle") { |
| 1578 | 1594 | return ( |
| 1579 | 1595 | <ThreadWorkGroupToggle |
| ... | ... | @@ -2870,6 +2886,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2870 | 2886 | // cards and related-thread links can exceed the compact row height. |
| 2871 | 2887 | const getFixedItemSize = useCallback( |
| 2872 | 2888 | (entry: ThreadFeedEntry) => { |
| 2889 | if (entry.type === "html-render") { | |
| 2890 | return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); | |
| 2891 | } | |
| 2873 | 2892 | if (workRowSizing.fixedRowHeight === undefined) { |
| 2874 | 2893 | return undefined; |
| 2875 | 2894 | } |
| ... | ... | @@ -2903,8 +2922,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2903 | 2922 | return undefined; |
| 2904 | 2923 | } |
| 2905 | 2924 | }, |
| 2906 | [expandedWorkRows, workRowSizing.fixedRowHeight], | |
| 2925 | [contentWidth, expandedWorkRows, workRowSizing.fixedRowHeight], | |
| 2907 | 2926 | ); |
| 2927 | // HTML render rows' fixed heights follow the width, so a rotation or split | |
| 2928 | // resize drops the cached sizes, as a text-size change does. | |
| 2929 | const hasHtmlRenders = useMemo( | |
| 2930 | () => presentedFeed.some((entry) => entry.type === "html-render"), | |
| 2931 | [presentedFeed], | |
| 2932 | ); | |
| 2933 | const previousContentWidth = useRef(contentWidth); | |
| 2934 | useLayoutEffect(() => { | |
| 2935 | if (previousContentWidth.current === contentWidth) return; | |
| 2936 | previousContentWidth.current = contentWidth; | |
| 2937 | if (hasHtmlRenders) props.listRef.current?.clearCaches({ mode: "sizes" }); | |
| 2938 | }, [contentWidth, hasHtmlRenders, props.listRef]); | |
| 2908 | 2939 | |
| 2909 | 2940 | // Disclosures can mount existing offscreen rows as well as new work rows. |
| 2910 | 2941 | // Fade those in after movement; never retain removed rows over replacements. |
| ... | ... | @@ -2947,6 +2978,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2947 | 2978 | themeAppearance, |
| 2948 | 2979 | userBubbleMaxWidth, |
| 2949 | 2980 | markdownContentWidth, |
| 2981 | contentWidth, | |
| 2950 | 2982 | threadTitle: props.threadTitle, |
| 2951 | 2983 | skills: props.skills, |
| 2952 | 2984 | workspaceRoot: props.workspaceRoot, |
| ... | ... | @@ -2983,6 +3015,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2983 | 3015 | themeAppearance, |
| 2984 | 3016 | userBubbleMaxWidth, |
| 2985 | 3017 | markdownContentWidth, |
| 3018 | contentWidth, | |
| 2986 | 3019 | onCopyWorkRow, |
| 2987 | 3020 | markdownLinkHandlers, |
| 2988 | 3021 | onPressPreview, |
apps/mobile/src/lib/htmlRenderTheme.test.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { mobileHtmlRenderTheme } from "./htmlRenderTheme"; | |
| 4 | import { getMobileThemeVariables } from "./mobileTheme"; | |
| 5 | ||
| 6 | describe("mobileHtmlRenderTheme", () => { | |
| 7 | it("paints the page with the feed's own canvas", () => { | |
| 8 | const variables = { | |
| 9 | ...getMobileThemeVariables("grove", "dark"), | |
| 10 | "--color-thread-canvas": "#101010", | |
| 11 | }; | |
| 12 | const theme = (platform: string) => | |
| 13 | mobileHtmlRenderTheme({ | |
| 14 | themeId: "grove", | |
| 15 | appearance: "dark", | |
| 16 | variables, | |
| 17 | systemColors: false, | |
| 18 | platform, | |
| 19 | }); | |
| 20 | expect(theme("android").variables["--background"]).toBe("#101010"); | |
| 21 | expect(theme("ios").variables["--background"]).toBe(variables["--color-screen"]); | |
| 22 | }); | |
| 23 | ||
| 24 | it("takes Material You's system roles over the default palette", () => { | |
| 25 | const variables = { | |
| 26 | ...getMobileThemeVariables("t3-code", "light"), | |
| 27 | "--color-primary": "#6750A4FF", | |
| 28 | "--color-foreground": "#1D1B20FF", | |
| 29 | }; | |
| 30 | const theme = mobileHtmlRenderTheme({ | |
| 31 | themeId: "material-you", | |
| 32 | appearance: "light", | |
| 33 | variables, | |
| 34 | systemColors: true, | |
| 35 | platform: "android", | |
| 36 | }); | |
| 37 | expect(theme.variables["--accent"]).toBe("#6750A4FF"); | |
| 38 | expect(theme.variables["--foreground"]).toBe("#1D1B20FF"); | |
| 39 | }); | |
| 40 | }); |
apps/mobile/src/lib/htmlRenderTheme.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender"; | |
| 2 | ||
| 3 | import { | |
| 4 | DEFAULT_MOBILE_THEME_ID, | |
| 5 | getMobileThemeColors, | |
| 6 | nativeColors, | |
| 7 | type MobileThemeAppearance, | |
| 8 | type MobileThemeId, | |
| 9 | type MobileThemeVariables, | |
| 10 | } from "./mobileTheme"; | |
| 11 | ||
| 12 | /** | |
| 13 | * The theme an HTML render styles against. Its background is the feed's own canvas. | |
| 14 | * Material You has no shared palette: it starts from the default one and takes the | |
| 15 | * roles its system colors replace. WebViews cannot load the app's bundled fonts, so | |
| 16 | * renders use system stacks. | |
| 17 | */ | |
| 18 | export function mobileHtmlRenderTheme(input: { | |
| 19 | readonly themeId: MobileThemeId; | |
| 20 | readonly appearance: MobileThemeAppearance; | |
| 21 | readonly variables: MobileThemeVariables; | |
| 22 | readonly systemColors: boolean; | |
| 23 | readonly platform: string; | |
| 24 | }) { | |
| 25 | const { variables: v } = input; | |
| 26 | const palette = getMobileThemeColors( | |
| 27 | input.themeId === "material-you" ? DEFAULT_MOBILE_THEME_ID : input.themeId, | |
| 28 | input.appearance, | |
| 29 | ); | |
| 30 | const colors = nativeColors( | |
| 31 | input.systemColors | |
| 32 | ? { | |
| 33 | ...palette, | |
| 34 | text: v["--color-foreground"], | |
| 35 | textMuted: v["--color-foreground-secondary"], | |
| 36 | muted: v["--color-subtle"], | |
| 37 | mutedForeground: v["--color-foreground-muted"], | |
| 38 | surface: v["--color-card"], | |
| 39 | surfaceRaised: v["--color-card-alt"], | |
| 40 | surfaceOverlay: v["--color-card-alt"], | |
| 41 | border: v["--color-border"], | |
| 42 | input: v["--color-input-border"], | |
| 43 | focus: v["--color-focus"], | |
| 44 | accent: v["--color-primary"], | |
| 45 | accentForeground: v["--color-primary-foreground"], | |
| 46 | accentSurface: v["--color-inline-skill-background"], | |
| 47 | accentSurfaceForeground: v["--color-inline-skill-foreground"], | |
| 48 | messageAction: v["--color-primary"], | |
| 49 | messageActionForeground: v["--color-primary-foreground"], | |
| 50 | secondary: v["--color-secondary"], | |
| 51 | secondaryForeground: v["--color-secondary-foreground"], | |
| 52 | errorSurface: v["--color-danger"], | |
| 53 | errorForeground: v["--color-danger-foreground"], | |
| 54 | codeBackground: v["--color-md-code-bg"], | |
| 55 | codeForeground: v["--color-md-code-text"], | |
| 56 | } | |
| 57 | : palette, | |
| 58 | ); | |
| 59 | // Android paints the thread on its own canvas role; iOS uses the screen. | |
| 60 | const canvas = v[input.platform === "android" ? "--color-thread-canvas" : "--color-screen"]; | |
| 61 | return htmlRenderTheme({ ...colors, canvas }, input.appearance, HTML_RENDER_DEFAULT_FONTS); | |
| 62 | } |
apps/mobile/src/lib/mobileTheme.ts+1-1| ... | ... | @@ -123,7 +123,7 @@ export function themeColorToNativeColor(value: string): string { |
| 123 | 123 | : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | function nativeColors(colors: ThemeColors): ThemeColors { | |
| 126 | export function nativeColors(colors: ThemeColors): ThemeColors { | |
| 127 | 127 | return Object.fromEntries( |
| 128 | 128 | Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]), |
| 129 | 129 | ) as ThemeColors; |
apps/mobile/src/lib/openExternalUrl.ts+1| ... | ... | @@ -6,6 +6,7 @@ const ExternalUrlTarget = Schema.Literals([ |
| 6 | 6 | "markdown-link", |
| 7 | 7 | "pull-request", |
| 8 | 8 | "provider-auth", |
| 9 | "html-render", | |
| 9 | 10 | ]); |
| 10 | 11 | |
| 11 | 12 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; |
apps/mobile/src/lib/threadActivity.test.ts+68| ... | ... | @@ -2375,3 +2375,71 @@ it.each(["provider_error", "usage_limit"] as const)( |
| 2375 | 2375 | }); |
| 2376 | 2376 | }, |
| 2377 | 2377 | ); |
| 2378 | ||
| 2379 | describe("html renders", () => { | |
| 2380 | const page = { attachmentId: "attachment-page", title: "Revenue", height: 320 }; | |
| 2381 | const renderCall = ( | |
| 2382 | overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>> = {}, | |
| 2383 | ): OrchestrationV2TurnItem => ({ | |
| 2384 | ...base("item-render", "2026-06-20T00:00:02.500Z", 2), | |
| 2385 | type: "dynamic_tool", | |
| 2386 | toolName: "mcp__t3-code__html_render", | |
| 2387 | input: { title: page.title }, | |
| 2388 | output: { htmlRender: page }, | |
| 2389 | ...overrides, | |
| 2390 | }); | |
| 2391 | const laterCommand = { | |
| 2392 | ...command("2026-06-20T00:00:02.800Z"), | |
| 2393 | id: TurnItemId.make("item-command-later"), | |
| 2394 | ordinal: 3, | |
| 2395 | }; | |
| 2396 | const feed = () => | |
| 2397 | buildThreadFeed([ | |
| 2398 | projected(userMessage(), 0), | |
| 2399 | projected(command(), 1), | |
| 2400 | projected(renderCall(), 2), | |
| 2401 | projected(laterCommand, 3), | |
| 2402 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 4), | |
| 2403 | ]); | |
| 2404 | const latestRun = { | |
| 2405 | runId, | |
| 2406 | status: "completed" as const, | |
| 2407 | startedAt: "2026-06-20T00:00:01.000Z", | |
| 2408 | completedAt: "2026-06-20T00:00:04.000Z", | |
| 2409 | }; | |
| 2410 | ||
| 2411 | it("shows a completed render in place, outside the work log", () => { | |
| 2412 | const expanded = deriveThreadFeedPresentation(feed(), latestRun, new Set([runId])); | |
| 2413 | expect(expanded.map((entry) => entry.type)).toEqual([ | |
| 2414 | "message", | |
| 2415 | "run-fold", | |
| 2416 | "work-toggle", | |
| 2417 | "html-render", | |
| 2418 | "work-toggle", | |
| 2419 | "message", | |
| 2420 | ]); | |
| 2421 | expect(expanded[3]).toMatchObject({ type: "html-render", render: page, runId }); | |
| 2422 | expect(expanded[2]?.continuesWorkLog).toBeUndefined(); | |
| 2423 | }); | |
| 2424 | ||
| 2425 | it("keeps a render visible and in order when its run folds", () => { | |
| 2426 | const collapsed = deriveThreadFeedPresentation(feed(), latestRun, new Set()); | |
| 2427 | expect(collapsed.map((entry) => entry.type)).toEqual([ | |
| 2428 | "message", | |
| 2429 | "run-fold", | |
| 2430 | "html-render", | |
| 2431 | "message", | |
| 2432 | ]); | |
| 2433 | }); | |
| 2434 | ||
| 2435 | it("leaves running, failed and errored renders in the work log", () => { | |
| 2436 | for (const call of [ | |
| 2437 | renderCall({ status: "running", output: null }), | |
| 2438 | renderCall({ status: "failed" }), | |
| 2439 | renderCall({ output: { isError: true, htmlRender: page } }), | |
| 2440 | ]) { | |
| 2441 | const entries = buildThreadFeed([projected(call, 0)]); | |
| 2442 | expect(entries.map((entry) => entry.type)).toEqual(["activity-group"]); | |
| 2443 | } | |
| 2444 | }); | |
| 2445 | }); |
apps/mobile/src/lib/threadActivity.ts+31-4| ... | ... | @@ -57,7 +57,8 @@ import { |
| 57 | 57 | formatSearchToolLabel, |
| 58 | 58 | } from "@t3tools/shared/toolActivity"; |
| 59 | 59 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 60 | import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; | |
| 60 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; | |
| 61 | import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | |
| 61 | 62 | import * as DateTime from "effect/DateTime"; |
| 62 | 63 | |
| 63 | 64 | export type PendingApproval = ThreadPendingApproval; |
| ... | ... | @@ -155,12 +156,20 @@ type RawThreadFeedEntry = |
| 155 | 156 | readonly createdAt: string; |
| 156 | 157 | readonly runId: RunId | null; |
| 157 | 158 | readonly activity: ThreadFeedActivity; |
| 159 | } | |
| 160 | | { | |
| 161 | /** A page a completed `html_render` call published, shown in place of its work row. */ | |
| 162 | readonly type: "html-render"; | |
| 163 | readonly id: string; | |
| 164 | readonly createdAt: string; | |
| 165 | readonly runId: RunId | null; | |
| 166 | readonly render: HtmlRenderReference; | |
| 158 | 167 | }; |
| 159 | 168 | |
| 160 | 169 | export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; |
| 161 | 170 | |
| 162 | 171 | type ThreadFeedEntryContent = |
| 163 | | Extract<RawThreadFeedEntry, { type: "message" }> | |
| 172 | | Extract<RawThreadFeedEntry, { type: "message" | "html-render" }> | |
| 164 | 173 | | { |
| 165 | 174 | readonly type: "activity-group"; |
| 166 | 175 | readonly id: string; |
| ... | ... | @@ -1020,7 +1029,7 @@ function deriveThreadFeedRunFolds( |
| 1020 | 1029 | const runId = |
| 1021 | 1030 | entry.type === "message" && entry.message.role === "assistant" |
| 1022 | 1031 | ? (entry.message.runId ?? runlessKey) |
| 1023 | : entry.type === "activity-group" | |
| 1032 | : entry.type === "activity-group" || entry.type === "html-render" | |
| 1024 | 1033 | ? (entry.runId ?? runlessKey) |
| 1025 | 1034 | : null; |
| 1026 | 1035 | if (!runId) continue; |
| ... | ... | @@ -1072,6 +1081,7 @@ function deriveThreadFeedRunFolds( |
| 1072 | 1081 | (entry) => |
| 1073 | 1082 | entry.id !== firstAssistantId && |
| 1074 | 1083 | entry.id !== terminalAssistantId && |
| 1084 | entry.type !== "html-render" && | |
| 1075 | 1085 | !( |
| 1076 | 1086 | entry.type === "activity-group" && |
| 1077 | 1087 | entry.activities.some( |
| ... | ... | @@ -1134,7 +1144,7 @@ const trailingReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedA |
| 1134 | 1144 | |
| 1135 | 1145 | /** A steer or subsequent activity ends thinking even if the provider omits its completion. */ |
| 1136 | 1146 | function settleSupersededReasoning( |
| 1137 | entry: Extract<ThreadFeedEntry, { readonly type: "message" | "activity-group" }>, | |
| 1147 | entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>, | |
| 1138 | 1148 | tail: boolean, |
| 1139 | 1149 | ) { |
| 1140 | 1150 | if (entry.type !== "activity-group") return entry; |
| ... | ... | @@ -1700,6 +1710,23 @@ export function buildThreadFeed( |
| 1700 | 1710 | continue; |
| 1701 | 1711 | } |
| 1702 | 1712 | const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); |
| 1713 | // A running or failed render stays an ordinary work row. | |
| 1714 | const render = | |
| 1715 | item.type === "dynamic_tool" && item.status === "completed" | |
| 1716 | ? htmlRenderFromToolItem(item) | |
| 1717 | : undefined; | |
| 1718 | if (render) { | |
| 1719 | const entry: RawThreadFeedEntry = { | |
| 1720 | type: "html-render", | |
| 1721 | id: `html-render:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, | |
| 1722 | createdAt, | |
| 1723 | runId: item.runId, | |
| 1724 | render, | |
| 1725 | }; | |
| 1726 | projectedEntriesCache.set(row, { attemptId, entry }); | |
| 1727 | entries.push(entry); | |
| 1728 | continue; | |
| 1729 | } | |
| 1703 | 1730 | if (item.type === "user_message" || item.type === "assistant_message") { |
| 1704 | 1731 | const updatedAt = DateTime.formatIso(item.updatedAt); |
| 1705 | 1732 | const entry: RawThreadFeedEntry = { |
apps/mobile/src/state/asset-url-state.test.ts+5-1| ... | ... | @@ -2,7 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import { deriveAssetUrlState } from "./asset-url-state"; |
| 4 | 4 | |
| 5 | const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" }; | |
| 5 | const SUCCESS = { | |
| 6 | _tag: "Success" as const, | |
| 7 | url: "https://environment.example/api/assets/abc", | |
| 8 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 9 | }; | |
| 6 | 10 | |
| 7 | 11 | describe("deriveAssetUrlState", () => { |
| 8 | 12 | it("passes a resolved URL through on a live environment", () => { |
apps/server/src/attachmentStore.test.ts+25| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | parseThreadSegmentFromAttachmentId, |
| 17 | 17 | resolveAttachmentPathById, |
| 18 | 18 | sweepStalePendingAttachments, |
| 19 | threadHtmlRenderAttachmentIds, | |
| 19 | 20 | } from "./attachmentStore.ts"; |
| 20 | 21 | |
| 21 | 22 | describe("attachmentStore", () => { |
| ... | ... | @@ -219,3 +220,27 @@ describe("attachmentStore", () => { |
| 219 | 220 | } |
| 220 | 221 | }); |
| 221 | 222 | }); |
| 223 | ||
| 224 | describe("threadHtmlRenderAttachmentIds", () => { | |
| 225 | it("returns pages this thread published and skips other threads' and failed calls", () => { | |
| 226 | const own = createAttachmentId("thread-a", "html")!; | |
| 227 | const other = createAttachmentId("thread-b", "html")!; | |
| 228 | const render = (attachmentId: string, extra = {}) => ({ | |
| 229 | toolName: "mcp__t3-code__html_render", | |
| 230 | output: [ | |
| 231 | { | |
| 232 | type: "text", | |
| 233 | text: JSON.stringify({ htmlRender: { attachmentId, title: "x", height: 300 }, ...extra }), | |
| 234 | }, | |
| 235 | ], | |
| 236 | }); | |
| 237 | expect( | |
| 238 | threadHtmlRenderAttachmentIds("thread-a", [ | |
| 239 | render(own), | |
| 240 | render(other), | |
| 241 | render(createAttachmentId("thread-a", "html")!, { isError: true }), | |
| 242 | { toolName: "mcp__t3-code__html_preview", output: render(own).output }, | |
| 243 | ]), | |
| 244 | ).toEqual([own]); | |
| 245 | }); | |
| 246 | }); |
apps/server/src/attachmentStore.ts+20| ... | ... | @@ -4,6 +4,7 @@ import * as NodeFS from "node:fs"; |
| 4 | 4 | import * as NodePath from "node:path"; |
| 5 | 5 | |
| 6 | 6 | import type { ChatAttachment } from "@t3tools/contracts"; |
| 7 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | |
| 7 | 8 | |
| 8 | 9 | import { |
| 9 | 10 | normalizeAttachmentRelativePath, |
| ... | ... | @@ -114,6 +115,25 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string |
| 114 | 115 | return match[1]?.toLowerCase() ?? null; |
| 115 | 116 | } |
| 116 | 117 | |
| 118 | /** | |
| 119 | * Attachments a thread's `html_render` tool calls published. Only ids minted | |
| 120 | * for this thread count, so deleting a fork never removes its source's pages. | |
| 121 | */ | |
| 122 | export function threadHtmlRenderAttachmentIds( | |
| 123 | threadId: string, | |
| 124 | items: Iterable<{ readonly toolName: string | null | undefined; readonly output?: unknown }>, | |
| 125 | ) { | |
| 126 | const segment = toSafeThreadAttachmentSegment(threadId); | |
| 127 | if (segment === null) return []; | |
| 128 | return Array.from(items).flatMap((item) => { | |
| 129 | const attachmentId = htmlRenderFromToolItem(item)?.attachmentId; | |
| 130 | return attachmentId !== undefined && | |
| 131 | parseThreadSegmentFromAttachmentId(attachmentId) === segment | |
| 132 | ? [attachmentId] | |
| 133 | : []; | |
| 134 | }); | |
| 135 | } | |
| 136 | ||
| 117 | 137 | /** Null for attachment types this build does not know; callers skip those. */ |
| 118 | 138 | export function attachmentRelativePath(attachment: ChatAttachment): string | null { |
| 119 | 139 | switch (attachment.type) { |
apps/server/src/htmlRender/HtmlRender.test.ts created+403| ... | ... | @@ -0,0 +1,403 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services. | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { describe, expect, it } from "@effect/vitest"; | |
| 4 | import { ThreadId } from "@t3tools/contracts"; | |
| 5 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 6 | import { | |
| 7 | HTML_RENDER_MEASURE_FONTS, | |
| 8 | HTML_RENDER_MEASURE_WIDTHS, | |
| 9 | htmlRenderTheme, | |
| 10 | } from "@t3tools/shared/htmlRender"; | |
| 11 | import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; | |
| 12 | import * as Deferred from "effect/Deferred"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Base64 from "effect/encoding/Base64"; | |
| 15 | import * as Fiber from "effect/Fiber"; | |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Path from "effect/Path"; | |
| 20 | import * as NodeDgram from "node:dgram"; | |
| 21 | import * as NodeHttp from "node:http"; | |
| 22 | import * as NodeURL from "node:url"; | |
| 23 | ||
| 24 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | |
| 25 | import * as ServerConfig from "../config.ts"; | |
| 26 | import * as HtmlRender from "./HtmlRender.ts"; | |
| 27 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 28 | ||
| 29 | // Real-browser tests run only when this names a chrome-headless-shell, for | |
| 30 | // example one T3 installed under <T3 home>/tools/chrome-headless-shell. | |
| 31 | const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL"; | |
| 32 | ||
| 33 | const htmlRenderLayer = ( | |
| 34 | executable?: string, | |
| 35 | installed: Effect.Effect<Option.Option<string>> = Effect.succeed( | |
| 36 | Option.fromUndefinedOr(executable), | |
| 37 | ), | |
| 38 | ) => | |
| 39 | HtmlRender.layer.pipe( | |
| 40 | Layer.provide( | |
| 41 | Layer.succeed( | |
| 42 | PreviewBrowser.PreviewBrowser, | |
| 43 | PreviewBrowser.PreviewBrowser.of({ | |
| 44 | executable: | |
| 45 | executable === undefined | |
| 46 | ? Effect.die("This test has no preview browser.") | |
| 47 | : Effect.succeed(executable), | |
| 48 | installed, | |
| 49 | }), | |
| 50 | ), | |
| 51 | ), | |
| 52 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-html-render-" })), | |
| 53 | Layer.provideMerge(NodeServices.layer), | |
| 54 | ); | |
| 55 | const testLayer = htmlRenderLayer(); | |
| 56 | ||
| 57 | const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); | |
| 58 | ||
| 59 | describe("HtmlRender", () => { | |
| 60 | it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () => | |
| 61 | Effect.gen(function* () { | |
| 62 | const fileSystem = yield* FileSystem.FileSystem; | |
| 63 | const path = yield* Path.Path; | |
| 64 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 65 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" }); | |
| 66 | const png = path.join(directory, "shot.png"); | |
| 67 | const svg = path.join(directory, "logo.svg"); | |
| 68 | yield* fileSystem.writeFile(png, PNG_BYTES); | |
| 69 | yield* fileSystem.writeFileString(svg, "<svg/>"); | |
| 70 | const kept = [ | |
| 71 | "https://example.com/a.png", | |
| 72 | "//cdn.example.com/b.png", | |
| 73 | "./c.png", | |
| 74 | "data:image/png;base64,AAAA", | |
| 75 | ]; | |
| 76 | ||
| 77 | const prepared = yield* htmlRender.prepare( | |
| 78 | [ | |
| 79 | "<!doctype html><html><head><title>Shots</title></head><body>", | |
| 80 | `<img src="${png}"><div style="background:url(${svg})"></div>`, | |
| 81 | `<script>const shots = ['${png}', \`${svg}\`];</script>`, | |
| 82 | ...kept.map((src) => `<img src="${src}">`), | |
| 83 | "</body></html>", | |
| 84 | ].join(""), | |
| 85 | ); | |
| 86 | ||
| 87 | const pngUri = `data:image/png;base64,${Base64.encode(PNG_BYTES)}`; | |
| 88 | const svgUri = `data:image/svg+xml;base64,${Base64.encode("<svg/>")}`; | |
| 89 | expect(prepared).toContain(`<img src="${pngUri}">`); | |
| 90 | expect(prepared).toContain(`url(${svgUri})`); | |
| 91 | expect(prepared).toContain(`['${pngUri}', \`${svgUri}\`]`); | |
| 92 | expect(prepared).not.toContain(directory); | |
| 93 | for (const src of kept) expect(prepared).toContain(`<img src="${src}">`); | |
| 94 | // The theme bootstrap opens the head, ahead of the page's own markup. | |
| 95 | expect(prepared.indexOf("<head>")).toBeLessThan(prepared.indexOf('<style id="t3-theme">')); | |
| 96 | expect(prepared.indexOf('<style id="t3-theme">')).toBeLessThan(prepared.indexOf("<title>")); | |
| 97 | }).pipe(Effect.provide(testLayer)), | |
| 98 | ); | |
| 99 | ||
| 100 | it.effect("inlines an SVG behind processing instructions and a doctype subset", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const fileSystem = yield* FileSystem.FileSystem; | |
| 103 | const path = yield* Path.Path; | |
| 104 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 105 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" }); | |
| 106 | const svg = path.join(directory, "styled.svg"); | |
| 107 | const source = [ | |
| 108 | '<?xml version="1.0"?>', | |
| 109 | '<?xml-stylesheet href="theme.css"?>', | |
| 110 | '<!DOCTYPE svg [ <!ENTITY fill "red"> ]>', | |
| 111 | '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"/>', | |
| 112 | ].join("\n"); | |
| 113 | yield* fileSystem.writeFileString(svg, source); | |
| 114 | ||
| 115 | const prepared = yield* htmlRender.prepare(`<img src="${svg}">`); | |
| 116 | ||
| 117 | expect(prepared).toContain(`data:image/svg+xml;base64,${Base64.encode(source)}`); | |
| 118 | }).pipe(Effect.provide(testLayer)), | |
| 119 | ); | |
| 120 | ||
| 121 | it.effect("lists every local image it cannot read", () => | |
| 122 | Effect.gen(function* () { | |
| 123 | const fileSystem = yield* FileSystem.FileSystem; | |
| 124 | const path = yield* Path.Path; | |
| 125 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 126 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" }); | |
| 127 | const folder = path.join(directory, "folder.png"); | |
| 128 | yield* fileSystem.makeDirectory(folder); | |
| 129 | const missing = path.join(directory, "missing.jpg"); | |
| 130 | // Named like an image, but a symlink or renamed file must not carry other data. | |
| 131 | const secret = path.join(directory, "secret.png"); | |
| 132 | yield* fileSystem.writeFileString(secret, "API_KEY=abc123"); | |
| 133 | const report = path.join(directory, "report.svg"); | |
| 134 | yield* fileSystem.writeFileString(report, "<!doctype html><body><svg></svg>API_KEY=abc123"); | |
| 135 | // An <svg> inside a quoted entity, and a prolog shaped to stall a backtracking matcher. | |
| 136 | const config = path.join(directory, "config.svg"); | |
| 137 | yield* fileSystem.writeFileString( | |
| 138 | config, | |
| 139 | '<!DOCTYPE config [<!ENTITY a "a"><!ENTITY b "]><svg/>">]><config>API_KEY=abc123</config>', | |
| 140 | ); | |
| 141 | const stalling = path.join(directory, "stalling.svg"); | |
| 142 | yield* fileSystem.writeFileString(stalling, `${"<?p?>".repeat(40)}<config><svg/></config>`); | |
| 143 | const unclosed = path.join(directory, "unclosed.svg"); | |
| 144 | yield* fileSystem.writeFileString(unclosed, '<!DOCTYPE svg [<!ENTITY a "x><svg/>'); | |
| 145 | // Roots named SVG or svgé are other elements. | |
| 146 | const upper = path.join(directory, "upper.svg"); | |
| 147 | yield* fileSystem.writeFileString(upper, "<SVG/>API_KEY=abc123"); | |
| 148 | const longer = path.join(directory, "longer.svg"); | |
| 149 | yield* fileSystem.writeFileString(longer, "<svg\u00e9/>API_KEY=abc123"); | |
| 150 | ||
| 151 | const error = yield* htmlRender | |
| 152 | .prepare( | |
| 153 | `<img src="${missing}"><img src='${folder}'><img src="C:\\nope\\shot.webp"><img src="${secret}"><img src="${report}"><img src="${config}"><img src="${stalling}"><img src="${unclosed}"><img src="${upper}"><img src="${longer}">`, | |
| 154 | ) | |
| 155 | .pipe(Effect.flip); | |
| 156 | ||
| 157 | expect(error).toBeInstanceOf(HtmlRender.HtmlRenderImagesNotFoundError); | |
| 158 | expect(error._tag === "HtmlRenderImagesNotFoundError" && error.paths).toEqual([ | |
| 159 | missing, | |
| 160 | folder, | |
| 161 | "C:\\nope\\shot.webp", | |
| 162 | secret, | |
| 163 | report, | |
| 164 | config, | |
| 165 | stalling, | |
| 166 | unclosed, | |
| 167 | upper, | |
| 168 | longer, | |
| 169 | ]); | |
| 170 | }).pipe(Effect.provide(testLayer)), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("publishes the prepared page as an html thread attachment", () => | |
| 174 | Effect.gen(function* () { | |
| 175 | const fileSystem = yield* FileSystem.FileSystem; | |
| 176 | const config = yield* ServerConfig.ServerConfig; | |
| 177 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 178 | ||
| 179 | const reference = yield* htmlRender.publish({ | |
| 180 | threadId: ThreadId.make("thread-html-render"), | |
| 181 | html: "<p>Quarterly revenue</p>", | |
| 182 | title: " Revenue ", | |
| 183 | height: 9_000, | |
| 184 | }); | |
| 185 | ||
| 186 | // Without an installed preview browser the page publishes unmeasured. | |
| 187 | expect(reference).toEqual({ | |
| 188 | attachmentId: expect.any(String), | |
| 189 | title: "Revenue", | |
| 190 | height: 2_000, | |
| 191 | }); | |
| 192 | const stored = resolveAttachmentPathById({ | |
| 193 | attachmentsDir: config.attachmentsDir, | |
| 194 | attachmentId: reference.attachmentId, | |
| 195 | }); | |
| 196 | expect(stored?.endsWith(".html")).toBe(true); | |
| 197 | const html = yield* fileSystem.readFileString(stored ?? ""); | |
| 198 | expect(html).toContain('<style id="t3-theme">'); | |
| 199 | expect(html).toContain("<p>Quarterly revenue</p>"); | |
| 200 | }).pipe(Effect.provide(testLayer)), | |
| 201 | ); | |
| 202 | ||
| 203 | it.effect("removes the page when publishing is interrupted", () => | |
| 204 | Effect.gen(function* () { | |
| 205 | const measuring = yield* Deferred.make<void>(); | |
| 206 | yield* Effect.gen(function* () { | |
| 207 | const fileSystem = yield* FileSystem.FileSystem; | |
| 208 | const config = yield* ServerConfig.ServerConfig; | |
| 209 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 210 | const storedPages = fileSystem | |
| 211 | .readDirectory(config.attachmentsDir, { recursive: true }) | |
| 212 | .pipe(Effect.map((names) => names.filter((name) => name.endsWith(".html")))); | |
| 213 | ||
| 214 | const publishing = yield* htmlRender | |
| 215 | .publish({ | |
| 216 | threadId: ThreadId.make("thread-html-cancel"), | |
| 217 | html: "<p>x</p>", | |
| 218 | title: "X", | |
| 219 | height: 200, | |
| 220 | }) | |
| 221 | .pipe(Effect.forkChild); | |
| 222 | yield* Deferred.await(measuring); | |
| 223 | expect(yield* storedPages).toHaveLength(1); | |
| 224 | yield* Fiber.interrupt(publishing); | |
| 225 | expect(yield* storedPages).toEqual([]); | |
| 226 | }).pipe( | |
| 227 | Effect.provide( | |
| 228 | htmlRenderLayer( | |
| 229 | undefined, | |
| 230 | Deferred.succeed(measuring, undefined).pipe(Effect.andThen(Effect.never)), | |
| 231 | ), | |
| 232 | ), | |
| 233 | ); | |
| 234 | }), | |
| 235 | ); | |
| 236 | ||
| 237 | it.live( | |
| 238 | "screenshots the page in headless Chrome with the requested theme and every console level", | |
| 239 | (ctx) => | |
| 240 | Effect.gen(function* () { | |
| 241 | const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; | |
| 242 | if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); | |
| 243 | yield* Effect.gen(function* () { | |
| 244 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 245 | const preview = yield* htmlRender.preview({ | |
| 246 | html: [ | |
| 247 | '<!doctype html><html><head></head><body><div style="height:300px;background:var(--accent)"></div>', | |
| 248 | '<img src="/nonexistent/t3-missing.png" hidden>', | |
| 249 | "<script>", | |
| 250 | "const root = getComputedStyle(document.documentElement);", | |
| 251 | 'console.log("ready", 3); console.info(root.getPropertyValue("--font-sans"));', | |
| 252 | 'console.warn(root.getPropertyValue("--background")); console.error("boom");', | |
| 253 | "</script>", | |
| 254 | '<script>throw new Error("broken chart");</script>', | |
| 255 | "</body></html>", | |
| 256 | ].join(""), | |
| 257 | width: 400, | |
| 258 | appearance: "dark", | |
| 259 | }); | |
| 260 | ||
| 261 | const png = Buffer.from(preview.png, "base64"); | |
| 262 | expect(png.readUInt32BE(0)).toBe(0x89504e47); | |
| 263 | expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([400, 300]); | |
| 264 | expect(preview).toMatchObject({ | |
| 265 | width: 400, | |
| 266 | contentHeight: 300, | |
| 267 | capturedHeight: 300, | |
| 268 | missingImages: ["/nonexistent/t3-missing.png"], | |
| 269 | }); | |
| 270 | // Headless Chrome prefers light, so a dark background proves the theme fragment applied. | |
| 271 | expect(preview.consoleMessages).toEqual( | |
| 272 | expect.arrayContaining([ | |
| 273 | { level: "log", text: "ready 3" }, | |
| 274 | { level: "info", text: HTML_RENDER_MEASURE_FONTS.sans }, | |
| 275 | { | |
| 276 | level: "warning", | |
| 277 | text: htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark").variables["--background"], | |
| 278 | }, | |
| 279 | { level: "error", text: "boom" }, | |
| 280 | { | |
| 281 | level: "error", | |
| 282 | text: expect.stringMatching(/^Error: broken chart\n\s+at page\.html:1:\d+$/), | |
| 283 | }, | |
| 284 | ]), | |
| 285 | ); | |
| 286 | }).pipe(Effect.provide(htmlRenderLayer(executable))); | |
| 287 | }), | |
| 288 | 30_000, | |
| 289 | ); | |
| 290 | ||
| 291 | it.live( | |
| 292 | "keeps every local file but the page itself out of the browser", | |
| 293 | (ctx) => | |
| 294 | Effect.gen(function* () { | |
| 295 | const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; | |
| 296 | if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); | |
| 297 | yield* Effect.gen(function* () { | |
| 298 | const fileSystem = yield* FileSystem.FileSystem; | |
| 299 | const path = yield* Path.Path; | |
| 300 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 301 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-files-" }); | |
| 302 | const secret = path.join(directory, "secret.js"); | |
| 303 | yield* fileSystem.writeFileString(secret, 'window.secret = "abc123";'); | |
| 304 | const secretUrl = NodeURL.pathToFileURL(secret).href; | |
| 305 | ||
| 306 | const preview = yield* htmlRender.preview({ | |
| 307 | html: [ | |
| 308 | `<script src="${secretUrl}"></script>`, | |
| 309 | `<iframe src="${secretUrl}" onload="console.log('frame loaded')"></iframe>`, | |
| 310 | '<script>addEventListener("load", () => console.log("secret:", window.secret ?? "none"));</script>', | |
| 311 | ].join(""), | |
| 312 | }); | |
| 313 | ||
| 314 | const texts = preview.consoleMessages.map((message) => message.text); | |
| 315 | expect(texts).toContain("secret: none"); | |
| 316 | expect(texts.join(" ")).not.toContain("abc123"); | |
| 317 | }).pipe(Effect.scoped, Effect.provide(htmlRenderLayer(executable))); | |
| 318 | }), | |
| 319 | 30_000, | |
| 320 | ); | |
| 321 | ||
| 322 | it.live( | |
| 323 | "keeps the page off this machine's local network", | |
| 324 | (ctx) => | |
| 325 | Effect.gen(function* () { | |
| 326 | const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; | |
| 327 | if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); | |
| 328 | // The page's connections go through a proxy that only reaches public | |
| 329 | // addresses, and WebRTC is gone. Each line below is a way out that | |
| 330 | // Chrome's own Local Network Access does not stop on its own. | |
| 331 | const requests: Array<string> = []; | |
| 332 | const server = yield* Effect.acquireRelease( | |
| 333 | Effect.callback<NodeHttp.Server>((resume) => { | |
| 334 | const listening = NodeHttp.createServer((request, response) => { | |
| 335 | requests.push(`${request.url} ${request.headers["sec-purpose"] ?? ""}`); | |
| 336 | response.end("<p>LOCAL</p>"); | |
| 337 | }); | |
| 338 | listening.listen(0, "127.0.0.1", () => resume(Effect.succeed(listening))); | |
| 339 | }), | |
| 340 | (listening) => | |
| 341 | Effect.callback<void>((resume) => { | |
| 342 | listening.close(() => resume(Effect.void)); | |
| 343 | }), | |
| 344 | ); | |
| 345 | const datagrams: Array<string> = []; | |
| 346 | const udp = yield* Effect.acquireRelease( | |
| 347 | Effect.callback<NodeDgram.Socket>((resume) => { | |
| 348 | const socket = NodeDgram.createSocket("udp4"); | |
| 349 | socket.on("message", (message) => datagrams.push(message.toString("hex"))); | |
| 350 | socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket))); | |
| 351 | }), | |
| 352 | (socket) => | |
| 353 | Effect.callback<void>((resume) => { | |
| 354 | socket.close(() => resume(Effect.void)); | |
| 355 | }), | |
| 356 | ); | |
| 357 | const address = server.address(); | |
| 358 | const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`; | |
| 359 | const stun = `stun:127.0.0.1:${udp.address().port}`; | |
| 360 | yield* Effect.gen(function* () { | |
| 361 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 362 | yield* htmlRender.preview({ | |
| 363 | html: [ | |
| 364 | `<img src="${origin}/x.png"><iframe src="${origin}/"></iframe>`, | |
| 365 | `<script type="speculationrules">{"prefetch":[{"source":"list","urls":["${origin}/prefetch"]}]}</script>`, | |
| 366 | `<script>fetch("${origin}/").catch(() => {}); new WebSocket("ws${origin.slice(4)}/socket");`, | |
| 367 | `try { const peer = new RTCPeerConnection({ iceServers: [{ urls: "${stun}" }] });`, | |
| 368 | `peer.createDataChannel("x"); peer.createOffer().then((offer) => peer.setLocalDescription(offer));`, | |
| 369 | `} catch {}`, | |
| 370 | `window.open("${origin}/popup"); location.href = "${origin}/navigate";</script>`, | |
| 371 | ].join(""), | |
| 372 | }); | |
| 373 | }).pipe(Effect.provide(htmlRenderLayer(executable))); | |
| 374 | expect(requests).toEqual([]); | |
| 375 | expect(datagrams).toEqual([]); | |
| 376 | }).pipe(Effect.scoped), | |
| 377 | 30_000, | |
| 378 | ); | |
| 379 | ||
| 380 | it.live( | |
| 381 | "measures a published page at every client width with a fresh load each", | |
| 382 | (ctx) => | |
| 383 | Effect.gen(function* () { | |
| 384 | const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; | |
| 385 | if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); | |
| 386 | yield* Effect.gen(function* () { | |
| 387 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 388 | // Like a D3 chart, the page sizes itself from the width once, at load. | |
| 389 | const reference = yield* htmlRender.publish({ | |
| 390 | threadId: ThreadId.make("thread-html-measure"), | |
| 391 | html: '<div id="chart"></div><script>document.getElementById("chart").style.height = innerWidth / 2 + "px";</script>', | |
| 392 | title: "Chart", | |
| 393 | height: 600, | |
| 394 | }); | |
| 395 | ||
| 396 | expect(reference.heights).toEqual( | |
| 397 | HTML_RENDER_MEASURE_WIDTHS.map((width) => [width, Math.ceil(width / 2)]), | |
| 398 | ); | |
| 399 | }).pipe(Effect.provide(htmlRenderLayer(executable))); | |
| 400 | }), | |
| 401 | 30_000, | |
| 402 | ); | |
| 403 | }); |
apps/server/src/htmlRender/HtmlRender.ts created+493| ... | ... | @@ -0,0 +1,493 @@ |
| 1 | import type { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { HostProcessUserId } from "@t3tools/shared/hostProcess"; | |
| 3 | import { | |
| 4 | clampHtmlRenderHeight, | |
| 5 | HTML_RENDER_COLUMN_WIDTH, | |
| 6 | HTML_RENDER_MAX_TITLE_LENGTH, | |
| 7 | HTML_RENDER_MEASURE_FONTS, | |
| 8 | HTML_RENDER_MEASURE_WIDTHS, | |
| 9 | htmlRenderTheme, | |
| 10 | htmlRenderThemeFragment, | |
| 11 | injectHtmlRenderBootstrap, | |
| 12 | type HtmlRenderReference, | |
| 13 | } from "@t3tools/shared/htmlRender"; | |
| 14 | import { | |
| 15 | T3_CODE_DARK_THEME_COLORS, | |
| 16 | T3_CODE_LIGHT_THEME_COLORS, | |
| 17 | type ThemeAppearance, | |
| 18 | } from "@t3tools/shared/themePalettes"; | |
| 19 | import * as Context from "effect/Context"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as FileSystem from "effect/FileSystem"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as Option from "effect/Option"; | |
| 24 | import type * as Path from "effect/Path"; | |
| 25 | import * as Schema from "effect/Schema"; | |
| 26 | import * as Semaphore from "effect/Semaphore"; | |
| 27 | import * as Stream from "effect/Stream"; | |
| 28 | import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 29 | ||
| 30 | import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; | |
| 31 | import { createAttachmentId } from "../attachmentStore.ts"; | |
| 32 | import * as ServerConfig from "../config.ts"; | |
| 33 | import * as HeadlessChrome from "./headlessChrome.ts"; | |
| 34 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 35 | ||
| 36 | const MIB = 1024 * 1024; | |
| 37 | const MAX_IMAGE_BYTES = 10 * MIB; | |
| 38 | const MAX_PAGE_BYTES = 25 * MIB; | |
| 39 | const formatMib = (bytes: number) => `${(bytes / MIB).toFixed(1)} MiB`; | |
| 40 | ||
| 41 | export class HtmlRenderImagesNotFoundError extends Schema.TaggedError<HtmlRenderImagesNotFoundError>()( | |
| 42 | "HtmlRenderImagesNotFoundError", | |
| 43 | { paths: Schema.Array(Schema.String) }, | |
| 44 | ) { | |
| 45 | override get message(): string { | |
| 46 | return `These local images could not be read: ${this.paths.join(", ")}. Use absolute paths to existing image files, or remove them.`; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export class HtmlRenderImageTooLargeError extends Schema.TaggedError<HtmlRenderImageTooLargeError>()( | |
| 51 | "HtmlRenderImageTooLargeError", | |
| 52 | { path: Schema.String, sizeBytes: Schema.Number }, | |
| 53 | ) { | |
| 54 | override get message(): string { | |
| 55 | return `${this.path} is ${formatMib(this.sizeBytes)}; each local image must be at most ${formatMib(MAX_IMAGE_BYTES)}.`; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export class HtmlRenderPageTooLargeError extends Schema.TaggedError<HtmlRenderPageTooLargeError>()( | |
| 60 | "HtmlRenderPageTooLargeError", | |
| 61 | { sizeBytes: Schema.Number }, | |
| 62 | ) { | |
| 63 | override get message(): string { | |
| 64 | return `With its images inlined the page is ${formatMib(this.sizeBytes)}; the limit is ${formatMib(MAX_PAGE_BYTES)}. Use smaller images.`; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export class HtmlRenderStoreError extends Schema.TaggedError<HtmlRenderStoreError>()( | |
| 69 | "HtmlRenderStoreError", | |
| 70 | { cause: Schema.Defect() }, | |
| 71 | ) { | |
| 72 | override get message(): string { | |
| 73 | return "The HTML render could not be saved."; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | export type HtmlRenderPrepareError = | |
| 78 | | HtmlRenderImagesNotFoundError | |
| 79 | | HtmlRenderImageTooLargeError | |
| 80 | | HtmlRenderPageTooLargeError; | |
| 81 | ||
| 82 | export interface HtmlPreview { | |
| 83 | /** Base64 PNG of the top `capturedHeight` pixels. */ | |
| 84 | readonly png: string; | |
| 85 | readonly width: number; | |
| 86 | /** Height the page needs to show without scrolling. */ | |
| 87 | readonly contentHeight: number; | |
| 88 | readonly capturedHeight: number; | |
| 89 | readonly consoleMessages: ReadonlyArray<HeadlessChrome.ConsoleMessage>; | |
| 90 | /** Local image paths that could not be read; they show as broken images. */ | |
| 91 | readonly missingImages?: ReadonlyArray<string>; | |
| 92 | } | |
| 93 | ||
| 94 | export class HtmlRender extends Context.Service< | |
| 95 | HtmlRender, | |
| 96 | { | |
| 97 | /** The self-contained page that gets stored: local images inlined, theme bootstrap injected. */ | |
| 98 | readonly prepare: (html: string) => Effect.Effect<string, HtmlRenderPrepareError>; | |
| 99 | /** | |
| 100 | * Stores a prepared page as a thread attachment for clients to show inline, | |
| 101 | * measuring its height at each client width when the preview browser is | |
| 102 | * already installed. | |
| 103 | */ | |
| 104 | readonly publish: (input: { | |
| 105 | readonly threadId: ThreadId; | |
| 106 | readonly html: string; | |
| 107 | readonly title: string; | |
| 108 | readonly height: number; | |
| 109 | }) => Effect.Effect<HtmlRenderReference, HtmlRenderPrepareError | HtmlRenderStoreError>; | |
| 110 | /** Screenshots a page in headless Chrome, tolerating unreadable local images. */ | |
| 111 | readonly preview: (input: { | |
| 112 | readonly html: string; | |
| 113 | readonly width?: number | undefined; | |
| 114 | readonly appearance?: ThemeAppearance | undefined; | |
| 115 | }) => Effect.Effect< | |
| 116 | HtmlPreview, | |
| 117 | | Exclude<HtmlRenderPrepareError, HtmlRenderImagesNotFoundError> | |
| 118 | | PreviewBrowser.PreviewBrowserInstallError | |
| 119 | | PreviewBrowser.PreviewBrowserInstallingError | |
| 120 | | PreviewBrowser.PreviewBrowserUnsupportedError | |
| 121 | | HeadlessChrome.HtmlRenderBrowserError | |
| 122 | >; | |
| 123 | } | |
| 124 | >()("t3/htmlRender/HtmlRender") {} | |
| 125 | ||
| 126 | const IMAGE_MIME_TYPES: Record<string, string> = { | |
| 127 | png: "image/png", | |
| 128 | jpg: "image/jpeg", | |
| 129 | jpeg: "image/jpeg", | |
| 130 | gif: "image/gif", | |
| 131 | webp: "image/webp", | |
| 132 | avif: "image/avif", | |
| 133 | svg: "image/svg+xml", | |
| 134 | bmp: "image/bmp", | |
| 135 | ico: "image/x-icon", | |
| 136 | }; | |
| 137 | const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|"); | |
| 138 | // POSIX `/…` (not protocol-relative `//…`) or Windows `C:\…` / `C:/…`. | |
| 139 | const ABSOLUTE_PATH = String.raw`(?:/(?!/)|[a-z]:[\\/])`; | |
| 140 | // An absolute image path that is a whole quoted string ("…", '…', `…`) or an | |
| 141 | // unquoted CSS url(…). URLs, data:, blob:, and relative paths never match. | |
| 142 | const LOCAL_IMAGE_PATTERN = new RegExp( | |
| 143 | String.raw`(["'\x60])(${ABSOLUTE_PATH}(?:(?!\1)[^\r\n]){0,2048}?\.(?:${IMAGE_EXTENSIONS}))\1` + | |
| 144 | String.raw`|url\(\s*(${ABSOLUTE_PATH}[^\s"'\x60()]{0,2048}?\.(?:${IMAGE_EXTENSIONS}))\s*\)`, | |
| 145 | "gid", | |
| 146 | ); | |
| 147 | ||
| 148 | const findLocalImages = (html: string) => | |
| 149 | Array.from(html.matchAll(LOCAL_IMAGE_PATTERN)).flatMap((match) => { | |
| 150 | const span = match.indices?.[2] ?? match.indices?.[3]; | |
| 151 | return span ? [{ start: span[0], end: span[1], path: html.slice(span[0], span[1]) }] : []; | |
| 152 | }); | |
| 153 | ||
| 154 | // Inside a JS string literal a Windows path's backslashes are escaped. | |
| 155 | const filePathFor = (reference: string) => | |
| 156 | /^[a-z]:/i.test(reference) ? reference.replaceAll("\\\\", "\\") : reference; | |
| 157 | ||
| 158 | const dataUriPrefix = (path: string) => | |
| 159 | `data:${IMAGE_MIME_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"};base64,`; | |
| 160 | ||
| 161 | const latin1 = (bytes: Uint8Array, start: number, end: number) => | |
| 162 | String.fromCharCode(...bytes.subarray(start, end)); | |
| 163 | ||
| 164 | /** | |
| 165 | * Whether file bytes are an image, whatever the file is named, so a symlink or | |
| 166 | * renamed file cannot carry other data, such as a secret, into a page. | |
| 167 | */ | |
| 168 | const isImageBytes = (bytes: Uint8Array) => { | |
| 169 | const head = latin1(bytes, 0, 12); | |
| 170 | if ( | |
| 171 | head.startsWith("\x89PNG") || | |
| 172 | head.startsWith("\xff\xd8\xff") || | |
| 173 | head.startsWith("GIF8") || | |
| 174 | head.startsWith("\0\0\x01\0") || | |
| 175 | (head.startsWith("BM") && head.slice(6, 10) === "\0\0\0\0") || | |
| 176 | (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") || | |
| 177 | /^ftyp(?:avif|avis|mif1)$/.test(head.slice(4, 12)) | |
| 178 | ) { | |
| 179 | return true; | |
| 180 | } | |
| 181 | return hasSvgRoot(new TextDecoder().decode(bytes.subarray(0, 4096))); | |
| 182 | }; | |
| 183 | ||
| 184 | /** The index just past `token` at or after `from`, or -1 when it never appears. */ | |
| 185 | const after = (text: string, token: string, from: number) => { | |
| 186 | const at = text.indexOf(token, from); | |
| 187 | return at === -1 ? -1 : at + token.length; | |
| 188 | }; | |
| 189 | ||
| 190 | /** | |
| 191 | * Whether an XML document's root element is <svg>, after any processing | |
| 192 | * instructions, comments, and a doctype. One forward pass, so no input can | |
| 193 | * make it slow, and quoted text never counts as markup. | |
| 194 | */ | |
| 195 | const hasSvgRoot = (text: string) => { | |
| 196 | let at = 0; | |
| 197 | while (at !== -1) { | |
| 198 | while (/\s/.test(text.charAt(at))) at += 1; | |
| 199 | if (text.startsWith("<?", at)) at = after(text, "?>", at + 2); | |
| 200 | else if (text.startsWith("<!--", at)) at = after(text, "-->", at + 4); | |
| 201 | else if (text.slice(at, at + 9).toLowerCase() === "<!doctype") at = afterDoctype(text, at + 9); | |
| 202 | // XML names are case-sensitive, and only these characters can end one here. | |
| 203 | else return /^<svg[ \t\r\n/>]/.test(text.slice(at, at + 5)); | |
| 204 | } | |
| 205 | return false; | |
| 206 | }; | |
| 207 | ||
| 208 | /** The index just past a doctype whose body starts at `from`, honoring quotes and its internal subset. */ | |
| 209 | const afterDoctype = (text: string, from: number) => { | |
| 210 | let inSubset = false; | |
| 211 | let at = from; | |
| 212 | while (at !== -1 && at < text.length) { | |
| 213 | const char = text[at]; | |
| 214 | if (char === '"' || char === "'") at = after(text, char, at + 1); | |
| 215 | else if (inSubset && text.startsWith("<!--", at)) at = after(text, "-->", at + 4); | |
| 216 | else if (inSubset && text.startsWith("<?", at)) at = after(text, "?>", at + 2); | |
| 217 | else if (char === ">" && !inSubset) return at + 1; | |
| 218 | else { | |
| 219 | if (char === "[") inSubset = true; | |
| 220 | else if (char === "]") inSubset = false; | |
| 221 | at += 1; | |
| 222 | } | |
| 223 | } | |
| 224 | return -1; | |
| 225 | }; | |
| 226 | ||
| 227 | /** Replaces every local image reference with a data URI; unreadable paths stay as written. */ | |
| 228 | const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (html: string) { | |
| 229 | const fileSystem = yield* FileSystem.FileSystem; | |
| 230 | const references = findLocalImages(html); | |
| 231 | const files = yield* Effect.forEach( | |
| 232 | [...new Set(references.map((reference) => reference.path))], | |
| 233 | (path) => | |
| 234 | fileSystem.stat(filePathFor(path)).pipe( | |
| 235 | Effect.map((info) => ({ | |
| 236 | path, | |
| 237 | size: info.type === "File" ? Number(info.size) : undefined, | |
| 238 | })), | |
| 239 | Effect.orElseSucceed(() => ({ path, size: undefined })), | |
| 240 | ), | |
| 241 | { concurrency: 8 }, | |
| 242 | ); | |
| 243 | const oversized = files.find((file) => file.size !== undefined && file.size > MAX_IMAGE_BYTES); | |
| 244 | if (oversized?.size !== undefined) { | |
| 245 | return yield* new HtmlRenderImageTooLargeError({ | |
| 246 | path: oversized.path, | |
| 247 | sizeBytes: oversized.size, | |
| 248 | }); | |
| 249 | } | |
| 250 | const sizes = new Map(files.map((file) => [file.path, file.size])); | |
| 251 | const pageBytes = references.reduce((total, reference) => { | |
| 252 | const size = sizes.get(reference.path); | |
| 253 | return size === undefined | |
| 254 | ? total | |
| 255 | : total + | |
| 256 | dataUriPrefix(reference.path).length + | |
| 257 | Math.ceil(size / 3) * 4 - | |
| 258 | Buffer.byteLength(reference.path); | |
| 259 | }, Buffer.byteLength(html)); | |
| 260 | if (pageBytes > MAX_PAGE_BYTES) { | |
| 261 | return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes }); | |
| 262 | } | |
| 263 | // Files can grow after `stat`. Each read stops one byte past the image | |
| 264 | // limit, and reading stops once the images read so far cannot fit the page. | |
| 265 | let readBytes = 0; | |
| 266 | const images = yield* Effect.forEach( | |
| 267 | files.filter((file) => file.size !== undefined), | |
| 268 | (file) => | |
| 269 | Effect.gen(function* () { | |
| 270 | const read = yield* fileSystem | |
| 271 | .stream(filePathFor(file.path), { bytesToRead: MAX_IMAGE_BYTES + 1 }) | |
| 272 | .pipe(Stream.mkUint8Array, Effect.option); | |
| 273 | if (Option.isNone(read) || !isImageBytes(read.value)) return []; | |
| 274 | const bytes = read.value; | |
| 275 | if (bytes.byteLength > MAX_IMAGE_BYTES) { | |
| 276 | return yield* new HtmlRenderImageTooLargeError({ | |
| 277 | path: file.path, | |
| 278 | sizeBytes: bytes.byteLength, | |
| 279 | }); | |
| 280 | } | |
| 281 | readBytes += Math.ceil(bytes.byteLength / 3) * 4; | |
| 282 | if (readBytes > MAX_PAGE_BYTES) { | |
| 283 | return yield* new HtmlRenderPageTooLargeError({ sizeBytes: readBytes }); | |
| 284 | } | |
| 285 | return [{ path: file.path, bytes }]; | |
| 286 | }), | |
| 287 | { concurrency: 4 }, | |
| 288 | ).pipe(Effect.map((entries) => entries.flat())); | |
| 289 | const dataUris = new Map( | |
| 290 | images.map( | |
| 291 | (image) => | |
| 292 | // Node's encoder: images run to 10 MiB. | |
| 293 | [ | |
| 294 | image.path, | |
| 295 | dataUriPrefix(image.path) + Buffer.from(image.bytes).toString("base64"), | |
| 296 | ] as const, | |
| 297 | ), | |
| 298 | ); | |
| 299 | const parts: Array<string> = []; | |
| 300 | let cursor = 0; | |
| 301 | for (const reference of references) { | |
| 302 | const dataUri = dataUris.get(reference.path); | |
| 303 | if (dataUri === undefined) continue; | |
| 304 | parts.push(html.slice(cursor, reference.start), dataUri); | |
| 305 | cursor = reference.end; | |
| 306 | } | |
| 307 | parts.push(html.slice(cursor)); | |
| 308 | const inlined = parts.join(""); | |
| 309 | const inlinedBytes = Buffer.byteLength(inlined); | |
| 310 | if (inlinedBytes > MAX_PAGE_BYTES) { | |
| 311 | return yield* new HtmlRenderPageTooLargeError({ sizeBytes: inlinedBytes }); | |
| 312 | } | |
| 313 | return { | |
| 314 | html: inlined, | |
| 315 | missing: files.filter((file) => !dataUris.has(file.path)).map((file) => file.path), | |
| 316 | }; | |
| 317 | }); | |
| 318 | ||
| 319 | const MIN_PREVIEW_WIDTH = 240; | |
| 320 | const MAX_PREVIEW_WIDTH = 1_600; | |
| 321 | // Each preview or measurement runs its own browser; more at once mostly costs memory. | |
| 322 | const MAX_CONCURRENT_BROWSERS = 2; | |
| 323 | // Publishing waits this long at most for heights before returning without them. | |
| 324 | const MEASURE_TIMEOUT = "6 seconds"; | |
| 325 | ||
| 326 | // Measured in the dark theme with Arial-metric fonts, so text wraps close to how clients show it. | |
| 327 | const MEASURE_FRAGMENT = htmlRenderThemeFragment( | |
| 328 | htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_MEASURE_FONTS), | |
| 329 | ); | |
| 330 | ||
| 331 | const make = Effect.gen(function* () { | |
| 332 | const fileSystem = yield* FileSystem.FileSystem; | |
| 333 | const config = yield* ServerConfig.ServerConfig; | |
| 334 | const previewBrowser = yield* PreviewBrowser.PreviewBrowser; | |
| 335 | const services = yield* Effect.context< | |
| 336 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 337 | >(); | |
| 338 | const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS); | |
| 339 | // Chrome refuses its sandbox as root. Other hosts that cannot provide one | |
| 340 | // (Ubuntu 23.10+ AppArmor) are learned from the first launch and remembered. | |
| 341 | let noSandbox = (yield* HostProcessUserId) === 0; | |
| 342 | ||
| 343 | const launching = <A>( | |
| 344 | run: ( | |
| 345 | noSandbox: boolean, | |
| 346 | ) => Effect.Effect< | |
| 347 | A, | |
| 348 | HeadlessChrome.HtmlRenderBrowserError | HeadlessChrome.HtmlRenderSandboxUnavailableError | |
| 349 | >, | |
| 350 | ) => | |
| 351 | browsers.withPermits(1)( | |
| 352 | // Read once a permit is held, so a queued call sees a fallback learned meanwhile. | |
| 353 | Effect.suspend(() => run(noSandbox)).pipe( | |
| 354 | Effect.catchTag("HtmlRenderSandboxUnavailableError", () => | |
| 355 | Effect.logInfo( | |
| 356 | "Chrome's sandbox is unavailable on this host; launching it without one.", | |
| 357 | ).pipe( | |
| 358 | Effect.andThen( | |
| 359 | Effect.sync(() => { | |
| 360 | noSandbox = true; | |
| 361 | }), | |
| 362 | ), | |
| 363 | Effect.andThen(run(true)), | |
| 364 | ), | |
| 365 | ), | |
| 366 | Effect.catchTag("HtmlRenderSandboxUnavailableError", (cause) => | |
| 367 | Effect.fail( | |
| 368 | new HeadlessChrome.HtmlRenderBrowserError({ | |
| 369 | reason: "the browser has no sandbox", | |
| 370 | cause, | |
| 371 | }), | |
| 372 | ), | |
| 373 | ), | |
| 374 | ), | |
| 375 | ); | |
| 376 | ||
| 377 | // Never installs the browser: html_render must not depend on it. | |
| 378 | const measure = (html: string) => | |
| 379 | Effect.gen(function* () { | |
| 380 | const executable = yield* previewBrowser.installed; | |
| 381 | if (Option.isNone(executable)) return undefined; | |
| 382 | const heights = yield* launching((noSandbox) => | |
| 383 | HeadlessChrome.measureHtmlHeights({ | |
| 384 | executable: executable.value, | |
| 385 | noSandbox, | |
| 386 | html, | |
| 387 | widths: HTML_RENDER_MEASURE_WIDTHS, | |
| 388 | urlFragment: MEASURE_FRAGMENT, | |
| 389 | }).pipe(Effect.provideContext(services)), | |
| 390 | ).pipe( | |
| 391 | Effect.timeoutOrElse({ | |
| 392 | duration: MEASURE_TIMEOUT, | |
| 393 | orElse: () => | |
| 394 | Effect.fail( | |
| 395 | new HeadlessChrome.HtmlRenderBrowserError({ | |
| 396 | reason: `measuring took longer than ${MEASURE_TIMEOUT}`, | |
| 397 | }), | |
| 398 | ), | |
| 399 | }), | |
| 400 | ); | |
| 401 | return heights.toSorted(([left], [right]) => left - right); | |
| 402 | }).pipe( | |
| 403 | Effect.catch((cause) => | |
| 404 | Effect.logWarning("Could not measure an HTML render; publishing it without heights.", { | |
| 405 | cause, | |
| 406 | }).pipe(Effect.as(undefined)), | |
| 407 | ), | |
| 408 | Effect.withSpan("HtmlRender.measure"), | |
| 409 | ); | |
| 410 | ||
| 411 | // The bootstrap goes in first so its head scan never runs over inlined image data. | |
| 412 | const inline = (html: string) => | |
| 413 | inlineLocalImages(injectHtmlRenderBootstrap(html)).pipe(Effect.provideContext(services)); | |
| 414 | ||
| 415 | const prepare = Effect.fn("HtmlRender.prepare")(function* (html: string) { | |
| 416 | const inlined = yield* inline(html); | |
| 417 | if (inlined.missing.length > 0) { | |
| 418 | return yield* new HtmlRenderImagesNotFoundError({ paths: inlined.missing }); | |
| 419 | } | |
| 420 | return inlined.html; | |
| 421 | }); | |
| 422 | ||
| 423 | const publish = Effect.fn("HtmlRender.publish")(function* (input: { | |
| 424 | readonly threadId: ThreadId; | |
| 425 | readonly html: string; | |
| 426 | readonly title: string; | |
| 427 | readonly height: number; | |
| 428 | }) { | |
| 429 | const html = yield* prepare(input.html); | |
| 430 | // The `-html` id suffix makes the asset route serve it as a sandboxed text/html document. | |
| 431 | const attachmentId = createAttachmentId(input.threadId, "html"); | |
| 432 | const filePath = | |
| 433 | attachmentId === null | |
| 434 | ? null | |
| 435 | : resolveAttachmentRelativePath({ | |
| 436 | attachmentsDir: config.attachmentsDir, | |
| 437 | relativePath: `${attachmentId}.html`, | |
| 438 | }); | |
| 439 | if (attachmentId === null || filePath === null) { | |
| 440 | return yield* new HtmlRenderStoreError({ cause: new Error("Invalid thread id.") }); | |
| 441 | } | |
| 442 | const heights = yield* fileSystem.writeFileString(filePath, html).pipe( | |
| 443 | Effect.mapError((cause) => new HtmlRenderStoreError({ cause })), | |
| 444 | Effect.andThen(measure(html)), | |
| 445 | // Only the returned reference lets thread deletion find the page, so a | |
| 446 | // publish that fails or is interrupted before returning removes it. | |
| 447 | Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)), | |
| 448 | ); | |
| 449 | return { | |
| 450 | attachmentId, | |
| 451 | title: input.title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML", | |
| 452 | height: clampHtmlRenderHeight(input.height), | |
| 453 | ...(heights === undefined ? {} : { heights }), | |
| 454 | } satisfies HtmlRenderReference; | |
| 455 | }); | |
| 456 | ||
| 457 | const preview = Effect.fn("HtmlRender.preview")(function* (input: { | |
| 458 | readonly html: string; | |
| 459 | readonly width?: number | undefined; | |
| 460 | readonly appearance?: ThemeAppearance | undefined; | |
| 461 | }) { | |
| 462 | const width = Math.min( | |
| 463 | MAX_PREVIEW_WIDTH, | |
| 464 | Math.max(MIN_PREVIEW_WIDTH, Math.round(input.width ?? HTML_RENDER_COLUMN_WIDTH)), | |
| 465 | ); | |
| 466 | const appearance = input.appearance ?? "dark"; | |
| 467 | const inlined = yield* inline(input.html); | |
| 468 | const executable = yield* previewBrowser.executable; | |
| 469 | const theme = htmlRenderTheme( | |
| 470 | appearance === "light" ? T3_CODE_LIGHT_THEME_COLORS : T3_CODE_DARK_THEME_COLORS, | |
| 471 | appearance, | |
| 472 | HTML_RENDER_MEASURE_FONTS, | |
| 473 | ); | |
| 474 | const screenshot = yield* launching((noSandbox) => | |
| 475 | HeadlessChrome.captureHtmlScreenshot({ | |
| 476 | executable, | |
| 477 | noSandbox, | |
| 478 | html: inlined.html, | |
| 479 | width, | |
| 480 | urlFragment: htmlRenderThemeFragment(theme), | |
| 481 | }).pipe(Effect.provideContext(services)), | |
| 482 | ); | |
| 483 | return { | |
| 484 | ...screenshot, | |
| 485 | width, | |
| 486 | ...(inlined.missing.length === 0 ? {} : { missingImages: inlined.missing }), | |
| 487 | } satisfies HtmlPreview; | |
| 488 | }); | |
| 489 | ||
| 490 | return HtmlRender.of({ prepare, publish, preview }); | |
| 491 | }); | |
| 492 | ||
| 493 | export const layer = Layer.effect(HtmlRender, make); |
apps/server/src/htmlRender/PreviewBrowser.test.ts created+296| ... | ... | @@ -0,0 +1,296 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import type * as Duration from "effect/Duration"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Fiber from "effect/Fiber"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 15 | import * as NodeCrypto from "node:crypto"; | |
| 16 | import * as NodeZlib from "node:zlib"; | |
| 17 | ||
| 18 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 19 | ||
| 20 | // POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. | |
| 21 | const hostPlatform: NodeJS.Platform = | |
| 22 | HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; | |
| 23 | const executableName = | |
| 24 | hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; | |
| 25 | const ROOT = "chrome-headless-shell-fixture/"; | |
| 26 | ||
| 27 | /** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */ | |
| 28 | const makeZip = ( | |
| 29 | entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>, | |
| 30 | ) => { | |
| 31 | const records: Array<Buffer> = []; | |
| 32 | const directory: Array<Buffer> = []; | |
| 33 | let offset = 0; | |
| 34 | for (const entry of entries) { | |
| 35 | const name = Buffer.from(entry.name); | |
| 36 | const data = Buffer.from(entry.data ?? ""); | |
| 37 | const crc = NodeZlib.crc32(data); | |
| 38 | const local = Buffer.alloc(30); | |
| 39 | local.writeUInt32LE(0x04034b50, 0); | |
| 40 | local.writeUInt16LE(20, 4); | |
| 41 | local.writeUInt32LE(crc, 14); | |
| 42 | local.writeUInt32LE(data.length, 18); | |
| 43 | local.writeUInt32LE(data.length, 22); | |
| 44 | local.writeUInt16LE(name.length, 26); | |
| 45 | const central = Buffer.alloc(46); | |
| 46 | central.writeUInt32LE(0x02014b50, 0); | |
| 47 | central.writeUInt16LE((3 << 8) | 20, 4); | |
| 48 | central.writeUInt16LE(20, 6); | |
| 49 | central.writeUInt32LE(crc, 16); | |
| 50 | central.writeUInt32LE(data.length, 20); | |
| 51 | central.writeUInt32LE(data.length, 24); | |
| 52 | central.writeUInt16LE(name.length, 28); | |
| 53 | central.writeUInt32LE((entry.mode << 16) >>> 0, 38); | |
| 54 | central.writeUInt32LE(offset, 42); | |
| 55 | records.push(local, name, data); | |
| 56 | directory.push(central, name); | |
| 57 | offset += local.length + name.length + data.length; | |
| 58 | } | |
| 59 | const centralDirectory = Buffer.concat(directory); | |
| 60 | const end = Buffer.alloc(22); | |
| 61 | end.writeUInt32LE(0x06054b50, 0); | |
| 62 | end.writeUInt16LE(entries.length, 8); | |
| 63 | end.writeUInt16LE(entries.length, 10); | |
| 64 | end.writeUInt32LE(centralDirectory.length, 12); | |
| 65 | end.writeUInt32LE(offset, 16); | |
| 66 | return Buffer.concat([...records, centralDirectory, end]); | |
| 67 | }; | |
| 68 | ||
| 69 | const browserArchive = makeZip([ | |
| 70 | { name: ROOT, mode: 0o40755 }, | |
| 71 | { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 }, | |
| 72 | { name: `${ROOT}libEGL.so`, data: "library", mode: 0o100755 }, | |
| 73 | { name: `${ROOT}locales/`, mode: 0o40755 }, | |
| 74 | { name: `${ROOT}locales/en-US.pak`, data: "strings", mode: 0o100644 }, | |
| 75 | ]); | |
| 76 | ||
| 77 | const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( | |
| 78 | options: { | |
| 79 | readonly archive?: Buffer; | |
| 80 | readonly sha256?: string; | |
| 81 | readonly body?: Stream.Stream<Uint8Array>; | |
| 82 | readonly wait?: Duration.Input; | |
| 83 | readonly unsupported?: boolean; | |
| 84 | } = {}, | |
| 85 | ) { | |
| 86 | const fs = yield* FileSystem.FileSystem; | |
| 87 | const path = yield* Path.Path; | |
| 88 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" }); | |
| 89 | const archive = options.archive ?? browserArchive; | |
| 90 | const requests: Array<string> = []; | |
| 91 | const browser = yield* PreviewBrowser.makePreviewBrowser({ | |
| 92 | baseDir, | |
| 93 | release: options.unsupported | |
| 94 | ? null | |
| 95 | : { | |
| 96 | version: "1.2.3", | |
| 97 | platform: "fixture", | |
| 98 | url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", | |
| 99 | bytes: archive.byteLength, | |
| 100 | sha256: options.sha256 ?? NodeCrypto.createHash("sha256").update(archive).digest("hex"), | |
| 101 | }, | |
| 102 | ...(options.wait === undefined ? {} : { wait: options.wait }), | |
| 103 | }).pipe( | |
| 104 | Effect.provideService(HostProcessPlatform, hostPlatform), | |
| 105 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 106 | Effect.provideService( | |
| 107 | HttpClient.HttpClient, | |
| 108 | HttpClient.make((request) => | |
| 109 | Effect.sync(() => { | |
| 110 | requests.push(request.url); | |
| 111 | const response = HttpClientResponse.fromWeb(request, new Response(null)); | |
| 112 | return Object.defineProperty(response, "stream", { | |
| 113 | value: options.body ?? Stream.make(archive.subarray(0, 40), archive.subarray(40)), | |
| 114 | }); | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | ); | |
| 119 | const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture"); | |
| 120 | return { browser, fs, path, baseDir, installRoot, requests }; | |
| 121 | }); | |
| 122 | ||
| 123 | it.layer(NodeServices.layer)("PreviewBrowser", (it) => { | |
| 124 | it.effect("installs a verified download with its file modes and then reuses it", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | const { browser, fs, path, installRoot, requests } = yield* makeHarness(); | |
| 127 | yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true }); | |
| 128 | // Another server's install in progress, and one abandoned two hours ago. | |
| 129 | const active = path.join(installRoot, ".install-other"); | |
| 130 | const abandoned = path.join(installRoot, ".install-abandoned"); | |
| 131 | yield* fs.makeDirectory(active); | |
| 132 | yield* fs.makeDirectory(abandoned); | |
| 133 | const twoHoursAgo = DateTime.toDate(DateTime.subtract(yield* DateTime.now, { hours: 2 })); | |
| 134 | yield* fs.utimes(abandoned, twoHoursAgo, twoHoursAgo); | |
| 135 | expect(yield* browser.installed).toEqual(Option.none()); | |
| 136 | ||
| 137 | const executable = yield* browser.executable; | |
| 138 | ||
| 139 | const version = path.join(installRoot, "1.2.3"); | |
| 140 | expect(executable).toBe(path.join(version, executableName)); | |
| 141 | expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings"); | |
| 142 | if (hostPlatform !== "win32") { | |
| 143 | const mode = (file: string) => | |
| 144 | fs.stat(path.join(version, file)).pipe(Effect.map((info) => info.mode & 0o777)); | |
| 145 | expect(yield* mode(executableName)).toBe(0o755); | |
| 146 | expect(yield* mode("libEGL.so")).toBe(0o755); | |
| 147 | expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644); | |
| 148 | } | |
| 149 | // The old build and stale staging directories are gone; a fresh one stays. | |
| 150 | expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([ | |
| 151 | ".install-other", | |
| 152 | "1.2.3", | |
| 153 | ]); | |
| 154 | expect(yield* browser.executable).toBe(executable); | |
| 155 | expect(yield* browser.installed).toEqual(Option.some(executable)); | |
| 156 | expect(requests).toHaveLength(1); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | it.effect.each([ | |
| 161 | { name: "a hash mismatch", sha256: "0".repeat(64) }, | |
| 162 | { name: "a short download", body: Stream.make(browserArchive.subarray(0, -1)) }, | |
| 163 | { | |
| 164 | name: "an oversized download", | |
| 165 | body: Stream.make(browserArchive, Buffer.from("extra")), | |
| 166 | }, | |
| 167 | ])("rejects $name, installs nothing, and retries on the next call", (testCase) => | |
| 168 | Effect.gen(function* () { | |
| 169 | const { browser, fs, installRoot, requests } = yield* makeHarness(testCase); | |
| 170 | ||
| 171 | const error = yield* browser.executable.pipe(Effect.flip); | |
| 172 | ||
| 173 | expect(error._tag).toBe("PreviewBrowserInstallError"); | |
| 174 | expect(error.message).toMatch( | |
| 175 | /^T3 could not install its HTML preview browser: .+ Call html_preview again to retry\.$/, | |
| 176 | ); | |
| 177 | expect(yield* fs.readDirectory(installRoot)).toEqual([]); | |
| 178 | yield* browser.executable.pipe(Effect.flip); | |
| 179 | expect(requests).toHaveLength(2); | |
| 180 | }), | |
| 181 | ); | |
| 182 | ||
| 183 | it.effect.each([ | |
| 184 | { name: "a parent-directory entry", entry: `${ROOT}../escape` }, | |
| 185 | { name: "an absolute entry", entry: "/escape" }, | |
| 186 | { name: "an entry outside the top directory", entry: "escape" }, | |
| 187 | ])("rejects an archive with $name", (testCase) => | |
| 188 | Effect.gen(function* () { | |
| 189 | const { browser, fs, path, baseDir, installRoot } = yield* makeHarness({ | |
| 190 | archive: makeZip([ | |
| 191 | { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 }, | |
| 192 | { name: testCase.entry, data: "escaped", mode: 0o100644 }, | |
| 193 | ]), | |
| 194 | }); | |
| 195 | ||
| 196 | const error = yield* browser.executable.pipe(Effect.flip); | |
| 197 | ||
| 198 | expect(error._tag).toBe("PreviewBrowserInstallError"); | |
| 199 | expect(yield* fs.readDirectory(installRoot)).toEqual([]); | |
| 200 | expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe( | |
| 201 | false, | |
| 202 | ); | |
| 203 | }), | |
| 204 | ); | |
| 205 | ||
| 206 | it.effect("shares one install between callers and finishes it after they stop waiting", () => | |
| 207 | Effect.gen(function* () { | |
| 208 | const firstChunkSent = yield* Deferred.make<void>(); | |
| 209 | const finishDownload = yield* Deferred.make<void>(); | |
| 210 | const { browser, requests } = yield* makeHarness({ | |
| 211 | wait: "45 seconds", | |
| 212 | body: Stream.concat( | |
| 213 | Stream.make(browserArchive.subarray(0, 100)), | |
| 214 | Stream.fromEffect( | |
| 215 | Deferred.succeed(firstChunkSent, undefined).pipe( | |
| 216 | Effect.andThen(Deferred.await(finishDownload)), | |
| 217 | Effect.as(browserArchive.subarray(100)), | |
| 218 | ), | |
| 219 | ), | |
| 220 | ), | |
| 221 | }); | |
| 222 | ||
| 223 | const callers = yield* Effect.forEach([1, 2], () => | |
| 224 | Effect.forkChild(browser.executable.pipe(Effect.flip)), | |
| 225 | ); | |
| 226 | yield* Deferred.await(firstChunkSent); | |
| 227 | yield* TestClock.adjust("45 seconds"); | |
| 228 | for (const caller of callers) { | |
| 229 | expect(yield* Fiber.join(caller)).toMatchObject({ | |
| 230 | _tag: "PreviewBrowserInstallingError", | |
| 231 | downloadedBytes: 100, | |
| 232 | unpacking: false, | |
| 233 | }); | |
| 234 | } | |
| 235 | ||
| 236 | // The install belongs to the service, so it outlives the callers that gave up on it. | |
| 237 | yield* Deferred.succeed(finishDownload, undefined); | |
| 238 | expect(yield* browser.executable).toMatch(new RegExp(`${executableName}$`)); | |
| 239 | expect(requests).toHaveLength(1); | |
| 240 | }), | |
| 241 | ); | |
| 242 | ||
| 243 | it.effect("reports hosts Chrome for Testing does not build for without downloading", () => | |
| 244 | Effect.gen(function* () { | |
| 245 | const { browser, requests } = yield* makeHarness({ unsupported: true }); | |
| 246 | expect((yield* browser.executable.pipe(Effect.flip))._tag).toBe( | |
| 247 | "PreviewBrowserUnsupportedError", | |
| 248 | ); | |
| 249 | expect(yield* browser.installed).toEqual(Option.none()); | |
| 250 | expect(requests).toEqual([]); | |
| 251 | }), | |
| 252 | ); | |
| 253 | }); | |
| 254 | ||
| 255 | it("pins one Chrome for Testing build per host", () => { | |
| 256 | const hosts = [ | |
| 257 | ["linux", "x64"], | |
| 258 | ["linux", "arm64"], | |
| 259 | ["darwin", "arm64"], | |
| 260 | ["darwin", "x64"], | |
| 261 | ["win32", "x64"], | |
| 262 | ["win32", "arm64"], | |
| 263 | ["win32", "ia32"], | |
| 264 | ["linux", "ia32"], | |
| 265 | ["freebsd", "x64"], | |
| 266 | ] as const; | |
| 267 | expect( | |
| 268 | hosts.map(([platform, arch]) => PreviewBrowser.previewBrowserRelease(platform, arch)?.platform), | |
| 269 | ).toEqual([ | |
| 270 | "linux64", | |
| 271 | "linux-arm64", | |
| 272 | "mac-arm64", | |
| 273 | "mac-x64", | |
| 274 | "win64", | |
| 275 | "win64", | |
| 276 | "win32", | |
| 277 | undefined, | |
| 278 | undefined, | |
| 279 | ]); | |
| 280 | expect(PreviewBrowser.previewBrowserRelease("linux", "x64")).toMatchObject({ | |
| 281 | url: "https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.92/linux64/chrome-headless-shell-linux64.zip", | |
| 282 | bytes: 120_477_194, | |
| 283 | }); | |
| 284 | }); | |
| 285 | ||
| 286 | it("tells the agent how far the install has come", () => { | |
| 287 | expect( | |
| 288 | new PreviewBrowser.PreviewBrowserInstallingError({ | |
| 289 | downloadedBytes: 37_200_000, | |
| 290 | totalBytes: 120_477_194, | |
| 291 | unpacking: false, | |
| 292 | }).message, | |
| 293 | ).toBe( | |
| 294 | "T3 is installing its HTML preview browser (37 of 120 MB downloaded). Call html_preview again in a minute.", | |
| 295 | ); | |
| 296 | }); |
apps/server/src/htmlRender/PreviewBrowser.ts created+462| ... | ... | @@ -0,0 +1,462 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. | |
| 2 | import * as EffectNodeStream from "@effect/platform-node/NodeStream"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import type * as Duration from "effect/Duration"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Path from "effect/Path"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 19 | import * as NodeCrypto from "node:crypto"; | |
| 20 | ||
| 21 | import * as ServerConfig from "../config.ts"; | |
| 22 | import { openZipArchive } from "../zipArchive.ts"; | |
| 23 | ||
| 24 | // HTML previews only ever run T3's own pinned Chrome for Testing headless | |
| 25 | // shell, never a browser the user installed. To bump the pin, pick a version | |
| 26 | // from https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json, | |
| 27 | // download each platform's chrome-headless-shell zip, and replace the version | |
| 28 | // and every byte count and SHA-256 below. Hosts drop the old build after the | |
| 29 | // new one installs. | |
| 30 | const VERSION = "154.0.8037.92"; | |
| 31 | const ARCHIVES = { | |
| 32 | linux64: { | |
| 33 | bytes: 120_477_194, | |
| 34 | sha256: "636aa5c79f2693632e9921b8bbb050038ba11672e02346c06c20f991aed096f9", | |
| 35 | }, | |
| 36 | "linux-arm64": { | |
| 37 | bytes: 121_182_296, | |
| 38 | sha256: "0ed0e47d9e9f639197f508d62ada09e5c6b4c4c60edab3160a9312a733091df6", | |
| 39 | }, | |
| 40 | "mac-arm64": { | |
| 41 | bytes: 99_221_129, | |
| 42 | sha256: "77da14e75d7f2568e6f7898d3df7cdc6faac74b15e903b2c9d486ebb6ca9b929", | |
| 43 | }, | |
| 44 | "mac-x64": { | |
| 45 | bytes: 104_748_425, | |
| 46 | sha256: "a54292aaacbb77f76f6ef47558e7c51ab884044e0adacca315567f83c060bcc4", | |
| 47 | }, | |
| 48 | win32: { | |
| 49 | bytes: 114_295_943, | |
| 50 | sha256: "56b30d2d6c35775ebf8dc3618680f6529e1c38c87f7feb28a16e9904273d51f7", | |
| 51 | }, | |
| 52 | win64: { | |
| 53 | bytes: 120_822_223, | |
| 54 | sha256: "3ac2561f02d9d87aadc0399d00b9002d718a4c365624fa67db9e7bfaf6b1a568", | |
| 55 | }, | |
| 56 | } as const; | |
| 57 | ||
| 58 | const chromePlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture) => { | |
| 59 | switch (platform) { | |
| 60 | case "linux": | |
| 61 | return arch === "x64" ? "linux64" : arch === "arm64" ? "linux-arm64" : null; | |
| 62 | case "darwin": | |
| 63 | return arch === "arm64" ? "mac-arm64" : arch === "x64" ? "mac-x64" : null; | |
| 64 | case "win32": | |
| 65 | // There is no Windows arm64 build; Windows on Arm runs the x64 one under emulation. | |
| 66 | return arch === "ia32" ? "win32" : arch === "x64" || arch === "arm64" ? "win64" : null; | |
| 67 | default: | |
| 68 | return null; | |
| 69 | } | |
| 70 | }; | |
| 71 | ||
| 72 | export interface PreviewBrowserRelease { | |
| 73 | readonly version: string; | |
| 74 | /** Chrome for Testing's platform name, which also names the archive's top directory. */ | |
| 75 | readonly platform: string; | |
| 76 | readonly url: string; | |
| 77 | readonly bytes: number; | |
| 78 | readonly sha256: string; | |
| 79 | } | |
| 80 | ||
| 81 | export const previewBrowserRelease = ( | |
| 82 | platform: NodeJS.Platform, | |
| 83 | arch: NodeJS.Architecture, | |
| 84 | ): PreviewBrowserRelease | null => { | |
| 85 | const chrome = chromePlatform(platform, arch); | |
| 86 | return chrome === null | |
| 87 | ? null | |
| 88 | : { | |
| 89 | version: VERSION, | |
| 90 | platform: chrome, | |
| 91 | url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-headless-shell-${chrome}.zip`, | |
| 92 | ...ARCHIVES[chrome], | |
| 93 | }; | |
| 94 | }; | |
| 95 | ||
| 96 | const megabytes = (bytes: number) => Math.round(bytes / 1_000_000); | |
| 97 | ||
| 98 | export class PreviewBrowserInstallError extends Schema.TaggedError<PreviewBrowserInstallError>()( | |
| 99 | "PreviewBrowserInstallError", | |
| 100 | { detail: Schema.String, cause: Schema.optional(Schema.Defect()) }, | |
| 101 | ) { | |
| 102 | override get message(): string { | |
| 103 | return `T3 could not install its HTML preview browser: ${this.detail} Call html_preview again to retry.`; | |
| 104 | } | |
| 105 | } | |
| 106 | const isInstallError = Schema.is(PreviewBrowserInstallError); | |
| 107 | ||
| 108 | export class PreviewBrowserInstallingError extends Schema.TaggedError<PreviewBrowserInstallingError>()( | |
| 109 | "PreviewBrowserInstallingError", | |
| 110 | { downloadedBytes: Schema.Number, totalBytes: Schema.Number, unpacking: Schema.Boolean }, | |
| 111 | ) { | |
| 112 | override get message(): string { | |
| 113 | const progress = this.unpacking | |
| 114 | ? "unpacking" | |
| 115 | : `${megabytes(this.downloadedBytes)} of ${megabytes(this.totalBytes)} MB downloaded`; | |
| 116 | return `T3 is installing its HTML preview browser (${progress}). Call html_preview again in a minute.`; | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | export class PreviewBrowserUnsupportedError extends Schema.TaggedError<PreviewBrowserUnsupportedError>()( | |
| 121 | "PreviewBrowserUnsupportedError", | |
| 122 | { platform: Schema.String, arch: Schema.String }, | |
| 123 | ) { | |
| 124 | override get message(): string { | |
| 125 | return `HTML previews are not available on ${this.platform}-${this.arch}: Chrome for Testing has no headless shell for it. html_render still works without a preview.`; | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | export class PreviewBrowser extends Context.Service< | |
| 130 | PreviewBrowser, | |
| 131 | { | |
| 132 | /** | |
| 133 | * Path to the installed headless shell. The first call starts the | |
| 134 | * install; callers wait for it up to a bound and then get its progress | |
| 135 | * instead, while the install keeps running. | |
| 136 | */ | |
| 137 | readonly executable: Effect.Effect< | |
| 138 | string, | |
| 139 | PreviewBrowserInstallError | PreviewBrowserInstallingError | PreviewBrowserUnsupportedError | |
| 140 | >; | |
| 141 | /** The installed headless shell, if any. Never starts or waits on an install. */ | |
| 142 | readonly installed: Effect.Effect<Option.Option<string>>; | |
| 143 | } | |
| 144 | >()("t3/htmlRender/PreviewBrowser") {} | |
| 145 | ||
| 146 | export interface PreviewBrowserOptions { | |
| 147 | readonly baseDir: string; | |
| 148 | readonly release?: PreviewBrowserRelease | null; | |
| 149 | /** How long a caller waits on an install in progress. Provider tool calls time out near 60s. */ | |
| 150 | readonly wait?: Duration.Input; | |
| 151 | } | |
| 152 | ||
| 153 | const DOWNLOAD_TIMEOUT = "15 minutes"; | |
| 154 | // Longer than any install can run: the download times out at 15 minutes. | |
| 155 | const STAGING_STALE_AFTER_MS = 60 * 60_000; | |
| 156 | ||
| 157 | const wrapFailure = (detail: string) => (cause: unknown) => | |
| 158 | isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause }); | |
| 159 | ||
| 160 | type InstallState = | |
| 161 | | { readonly _tag: "idle" } | |
| 162 | | { | |
| 163 | readonly _tag: "installing"; | |
| 164 | readonly done: Deferred.Deferred<string, PreviewBrowserInstallError>; | |
| 165 | } | |
| 166 | | { readonly _tag: "installed"; readonly executable: string } | |
| 167 | | { readonly _tag: "failed"; readonly error: PreviewBrowserInstallError }; | |
| 168 | ||
| 169 | export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( | |
| 170 | options: PreviewBrowserOptions, | |
| 171 | ) { | |
| 172 | const fs = yield* FileSystem.FileSystem; | |
| 173 | const path = yield* Path.Path; | |
| 174 | const http = yield* HttpClient.HttpClient; | |
| 175 | // Installs belong to the service, so they finish even when no caller is still waiting. | |
| 176 | const serviceScope = yield* Effect.scope; | |
| 177 | const platform = yield* HostProcessPlatform; | |
| 178 | const arch = yield* HostProcessArchitecture; | |
| 179 | const release = | |
| 180 | options.release === undefined ? previewBrowserRelease(platform, arch) : options.release; | |
| 181 | const wait = options.wait ?? "45 seconds"; | |
| 182 | const installRoot = path.join( | |
| 183 | options.baseDir, | |
| 184 | "tools", | |
| 185 | "chrome-headless-shell", | |
| 186 | release?.platform ?? `${platform}-${arch}`, | |
| 187 | ); | |
| 188 | const executableName = | |
| 189 | platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; | |
| 190 | const gate = yield* Semaphore.make(1); | |
| 191 | let state: InstallState = { _tag: "idle" }; | |
| 192 | const progress = { downloadedBytes: 0, unpacking: false }; | |
| 193 | ||
| 194 | // The version directory appears by one rename of a fully unpacked tree, so a | |
| 195 | // runnable binary there is the complete-install marker. | |
| 196 | const installedExecutable = (release: PreviewBrowserRelease) => { | |
| 197 | const executable = path.join(installRoot, release.version, executableName); | |
| 198 | return fs.stat(executable).pipe( | |
| 199 | Effect.map((info) => | |
| 200 | info.type === "File" && (platform === "win32" || (info.mode & 0o111) !== 0) | |
| 201 | ? Option.some(executable) | |
| 202 | : Option.none<string>(), | |
| 203 | ), | |
| 204 | Effect.orElseSucceed(() => Option.none<string>()), | |
| 205 | ); | |
| 206 | }; | |
| 207 | ||
| 208 | const download = Effect.fn("PreviewBrowser.download")( | |
| 209 | function* (release: PreviewBrowserRelease, archivePath: string) { | |
| 210 | const response = yield* http | |
| 211 | .execute(HttpClientRequest.get(release.url)) | |
| 212 | .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); | |
| 213 | const contentLength = response.headers["content-length"]; | |
| 214 | const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); | |
| 215 | if ( | |
| 216 | (contentEncoding === undefined || contentEncoding === "identity") && | |
| 217 | contentLength !== undefined && | |
| 218 | Number(contentLength) !== release.bytes | |
| 219 | ) { | |
| 220 | return yield* new PreviewBrowserInstallError({ | |
| 221 | detail: "The download size did not match the pinned release.", | |
| 222 | }); | |
| 223 | } | |
| 224 | const hash = NodeCrypto.createHash("sha256"); | |
| 225 | yield* response.stream.pipe( | |
| 226 | Stream.tap((chunk) => | |
| 227 | Effect.suspend(() => { | |
| 228 | progress.downloadedBytes += chunk.byteLength; | |
| 229 | if (progress.downloadedBytes > release.bytes) { | |
| 230 | return Effect.fail( | |
| 231 | new PreviewBrowserInstallError({ | |
| 232 | detail: "The download was larger than the pinned release.", | |
| 233 | }), | |
| 234 | ); | |
| 235 | } | |
| 236 | hash.update(chunk); | |
| 237 | return Effect.void; | |
| 238 | }), | |
| 239 | ), | |
| 240 | Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), | |
| 241 | ); | |
| 242 | if (progress.downloadedBytes !== release.bytes || hash.digest("hex") !== release.sha256) { | |
| 243 | return yield* new PreviewBrowserInstallError({ | |
| 244 | detail: "The download failed its size or SHA-256 check. Nothing was installed.", | |
| 245 | }); | |
| 246 | } | |
| 247 | }, | |
| 248 | Effect.timeout(DOWNLOAD_TIMEOUT), | |
| 249 | Effect.mapError( | |
| 250 | wrapFailure( | |
| 251 | "The download from storage.googleapis.com failed. Check this machine's network access.", | |
| 252 | ), | |
| 253 | ), | |
| 254 | ); | |
| 255 | ||
| 256 | const extract = Effect.fn("PreviewBrowser.extract")( | |
| 257 | function* (release: PreviewBrowserRelease, archivePath: string, destination: string) { | |
| 258 | const archive = yield* openZipArchive( | |
| 259 | archivePath, | |
| 260 | (detail, cause) => new PreviewBrowserInstallError({ detail, cause }), | |
| 261 | ); | |
| 262 | const unsafe = new PreviewBrowserInstallError({ | |
| 263 | detail: "The archive contains an unexpected or unsafe entry.", | |
| 264 | }); | |
| 265 | const root = `chrome-headless-shell-${release.platform}/`; | |
| 266 | yield* fs.makeDirectory(destination); | |
| 267 | let foundExecutable = false; | |
| 268 | for (;;) { | |
| 269 | const entry = yield* archive.next; | |
| 270 | if (!entry) break; | |
| 271 | const unixMode = entry.externalFileAttributes >>> 16; | |
| 272 | const unixType = unixMode & 0o170000; | |
| 273 | const isDirectory = entry.fileName.endsWith("/"); | |
| 274 | // Everything sits under the one top directory, which is stripped. | |
| 275 | const relative = entry.fileName.startsWith(root) ? entry.fileName.slice(root.length) : null; | |
| 276 | const target = relative === null ? null : path.resolve(destination, relative); | |
| 277 | if ( | |
| 278 | relative === null || | |
| 279 | target === null || | |
| 280 | (relative !== "" && !target.startsWith(`${destination}${path.sep}`)) || | |
| 281 | relative.split("/").includes("..") || | |
| 282 | (unixType !== 0 && unixType !== (isDirectory ? 0o040000 : 0o100000)) || | |
| 283 | (entry.generalPurposeBitFlag & 1) !== 0 || | |
| 284 | ![0, 8].includes(entry.compressionMethod) | |
| 285 | ) { | |
| 286 | return yield* unsafe; | |
| 287 | } | |
| 288 | if (isDirectory) { | |
| 289 | yield* fs.makeDirectory(target, { recursive: true }); | |
| 290 | continue; | |
| 291 | } | |
| 292 | yield* fs.makeDirectory(path.dirname(target), { recursive: true }); | |
| 293 | yield* Effect.gen(function* () { | |
| 294 | const readable = yield* archive.streamEntry(entry); | |
| 295 | yield* EffectNodeStream.fromReadable<Uint8Array, PreviewBrowserInstallError>({ | |
| 296 | evaluate: () => readable, | |
| 297 | onError: wrapFailure("Could not unpack the browser."), | |
| 298 | }).pipe(Stream.run(fs.sink(target, { flag: "wx" }))); | |
| 299 | }).pipe(Effect.scoped); | |
| 300 | // The binary and its shared libraries carry exec bits in the archive. | |
| 301 | if (platform !== "win32" && (unixMode & 0o777) !== 0) { | |
| 302 | yield* fs.chmod(target, unixMode & 0o777); | |
| 303 | } | |
| 304 | if (relative === executableName) foundExecutable = true; | |
| 305 | } | |
| 306 | if (!foundExecutable) { | |
| 307 | return yield* new PreviewBrowserInstallError({ | |
| 308 | detail: "The archive does not contain chrome-headless-shell.", | |
| 309 | }); | |
| 310 | } | |
| 311 | }, | |
| 312 | Effect.scoped, | |
| 313 | Effect.mapError( | |
| 314 | wrapFailure("Could not unpack the browser. Check free disk space in T3's home directory."), | |
| 315 | ), | |
| 316 | ); | |
| 317 | ||
| 318 | const install = Effect.fn("PreviewBrowser.install")( | |
| 319 | function* (release: PreviewBrowserRelease) { | |
| 320 | yield* Effect.gen(function* () { | |
| 321 | yield* fs.makeDirectory(installRoot, { recursive: true }); | |
| 322 | // Staging shares the install root's filesystem so publishing is one rename. | |
| 323 | const staging = yield* fs.makeTempDirectoryScoped({ | |
| 324 | directory: installRoot, | |
| 325 | prefix: ".install-", | |
| 326 | }); | |
| 327 | const archivePath = path.join(staging, "download.zip"); | |
| 328 | const unpacked = path.join(staging, "browser"); | |
| 329 | yield* download(release, archivePath); | |
| 330 | progress.unpacking = true; | |
| 331 | yield* extract(release, archivePath, unpacked); | |
| 332 | const destination = path.join(installRoot, release.version); | |
| 333 | // A directory left without a runnable binary would block the rename. | |
| 334 | yield* fs.remove(destination, { recursive: true, force: true }); | |
| 335 | yield* fs.rename(unpacked, destination); | |
| 336 | }).pipe(Effect.scoped); | |
| 337 | // Older builds and abandoned staging directories. Two servers can share a | |
| 338 | // home, so a staging directory touched within the last hour may be another | |
| 339 | // server's install in progress and stays. | |
| 340 | const entries = yield* fs.readDirectory(installRoot); | |
| 341 | const now = yield* Clock.currentTimeMillis; | |
| 342 | yield* Effect.forEach( | |
| 343 | entries.filter((name) => name !== release.version), | |
| 344 | (name) => | |
| 345 | Effect.gen(function* () { | |
| 346 | const target = path.join(installRoot, name); | |
| 347 | if (name.startsWith(".install-")) { | |
| 348 | const info = yield* fs.stat(target); | |
| 349 | const modifiedAt = Option.match(info.mtime, { | |
| 350 | onNone: () => now, | |
| 351 | onSome: (date) => date.getTime(), | |
| 352 | }); | |
| 353 | if (now - modifiedAt < STAGING_STALE_AFTER_MS) return; | |
| 354 | } | |
| 355 | yield* fs.remove(target, { recursive: true, force: true }); | |
| 356 | }).pipe( | |
| 357 | Effect.catch((cause) => | |
| 358 | Effect.logWarning("Could not remove an old preview browser.", { name, cause }), | |
| 359 | ), | |
| 360 | ), | |
| 361 | { discard: true }, | |
| 362 | ); | |
| 363 | return path.join(installRoot, release.version, executableName); | |
| 364 | }, | |
| 365 | Effect.mapError(wrapFailure("Could not save the browser in T3's home directory.")), | |
| 366 | ); | |
| 367 | ||
| 368 | // Joins the current install or starts one. A failure is reported once, then cleared. | |
| 369 | const join = (release: PreviewBrowserRelease) => | |
| 370 | gate.withPermit( | |
| 371 | Effect.gen(function* () { | |
| 372 | if (state._tag === "installing") return state; | |
| 373 | if (state._tag === "failed") { | |
| 374 | const failed = state; | |
| 375 | state = { _tag: "idle" }; | |
| 376 | return failed; | |
| 377 | } | |
| 378 | const installed = yield* installedExecutable(release); | |
| 379 | if (Option.isSome(installed)) { | |
| 380 | state = { _tag: "installed", executable: installed.value }; | |
| 381 | return state; | |
| 382 | } | |
| 383 | const done = yield* Deferred.make<string, PreviewBrowserInstallError>(); | |
| 384 | progress.downloadedBytes = 0; | |
| 385 | progress.unpacking = false; | |
| 386 | const installing: InstallState = { _tag: "installing", done }; | |
| 387 | state = installing; | |
| 388 | yield* install(release).pipe( | |
| 389 | Effect.onExit((exit) => | |
| 390 | gate | |
| 391 | .withPermit( | |
| 392 | Effect.sync(() => { | |
| 393 | state = Exit.isSuccess(exit) | |
| 394 | ? { _tag: "installed", executable: exit.value } | |
| 395 | : { | |
| 396 | _tag: "failed", | |
| 397 | error: Option.getOrElse( | |
| 398 | Cause.findErrorOption(exit.cause), | |
| 399 | () => new PreviewBrowserInstallError({ detail: "The install stopped." }), | |
| 400 | ), | |
| 401 | }; | |
| 402 | }), | |
| 403 | ) | |
| 404 | .pipe(Effect.andThen(Deferred.done(done, exit))), | |
| 405 | ), | |
| 406 | Effect.ignoreCause, | |
| 407 | // Server shutdown closes the service scope and stops an install in progress. | |
| 408 | Effect.interruptible, | |
| 409 | Effect.forkIn(serviceScope), | |
| 410 | ); | |
| 411 | return installing; | |
| 412 | }), | |
| 413 | ); | |
| 414 | ||
| 415 | // A waiter that saw the failure has reported it; the next call retries. | |
| 416 | const clearFailure = (error: PreviewBrowserInstallError) => | |
| 417 | gate.withPermit( | |
| 418 | Effect.sync(() => { | |
| 419 | if (state._tag === "failed" && state.error === error) state = { _tag: "idle" }; | |
| 420 | }), | |
| 421 | ); | |
| 422 | ||
| 423 | const executable = Effect.gen(function* () { | |
| 424 | if (release === null) { | |
| 425 | return yield* new PreviewBrowserUnsupportedError({ platform, arch }); | |
| 426 | } | |
| 427 | const current = yield* join(release); | |
| 428 | switch (current._tag) { | |
| 429 | case "installed": | |
| 430 | return current.executable; | |
| 431 | case "failed": | |
| 432 | return yield* current.error; | |
| 433 | case "installing": | |
| 434 | return yield* Deferred.await(current.done).pipe( | |
| 435 | Effect.tapError(clearFailure), | |
| 436 | Effect.timeoutOrElse({ | |
| 437 | duration: wait, | |
| 438 | orElse: () => | |
| 439 | Effect.fail( | |
| 440 | new PreviewBrowserInstallingError({ | |
| 441 | downloadedBytes: progress.downloadedBytes, | |
| 442 | totalBytes: release.bytes, | |
| 443 | unpacking: progress.unpacking, | |
| 444 | }), | |
| 445 | ), | |
| 446 | }), | |
| 447 | ); | |
| 448 | } | |
| 449 | }).pipe(Effect.withSpan("PreviewBrowser.executable")); | |
| 450 | ||
| 451 | const installed = release === null ? Effect.succeedNone : installedExecutable(release); | |
| 452 | ||
| 453 | return PreviewBrowser.of({ executable, installed }); | |
| 454 | }); | |
| 455 | ||
| 456 | export const layer = Layer.effect( | |
| 457 | PreviewBrowser, | |
| 458 | Effect.gen(function* () { | |
| 459 | const config = yield* ServerConfig.ServerConfig; | |
| 460 | return yield* makePreviewBrowser({ baseDir: config.baseDir }); | |
| 461 | }), | |
| 462 | ); |
apps/server/src/htmlRender/headlessChrome.ts created+609| ... | ... | @@ -0,0 +1,609 @@ |
| 1 | import * as Deferred from "effect/Deferred"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Fiber from "effect/Fiber"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Queue from "effect/Queue"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; | |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 12 | ||
| 13 | import { publicProxy } from "./publicProxy.ts"; | |
| 14 | ||
| 15 | export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()( | |
| 16 | "HtmlRenderBrowserError", | |
| 17 | { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, | |
| 18 | ) { | |
| 19 | override get message(): string { | |
| 20 | return `Headless Chrome could not render the page: ${this.reason}.`; | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | /** The browser died at startup because this host cannot give it a sandbox. */ | |
| 25 | export class HtmlRenderSandboxUnavailableError extends Schema.TaggedError<HtmlRenderSandboxUnavailableError>()( | |
| 26 | "HtmlRenderSandboxUnavailableError", | |
| 27 | {}, | |
| 28 | ) { | |
| 29 | override get message(): string { | |
| 30 | return "Chrome's sandbox is unavailable on this host."; | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | type BrowserFailure = HtmlRenderBrowserError | HtmlRenderSandboxUnavailableError; | |
| 35 | ||
| 36 | export interface ConsoleMessage { | |
| 37 | readonly level: "log" | "info" | "warning" | "error"; | |
| 38 | readonly text: string; | |
| 39 | } | |
| 40 | ||
| 41 | const MAX_CONSOLE_MESSAGES = 20; | |
| 42 | const MAX_CONSOLE_TEXT_CHARS = 500; | |
| 43 | const VIEWPORT_HEIGHT = 800; | |
| 44 | const MAX_CAPTURE_HEIGHT = 4_000; | |
| 45 | const CAPTURE_TIMEOUT = "20 seconds"; | |
| 46 | // Pages load from this made-up web origin, never from a file. Chrome refuses | |
| 47 | // local files to every web page, frame, worker, and popup, so a page cannot | |
| 48 | // read files the agent's provider withholds; local images reach it already | |
| 49 | // inlined as data URIs. `.localhost` keeps it a secure context that may still | |
| 50 | // load plain-http resources, and the request never leaves the browser. | |
| 51 | // All of the browser's traffic goes through `publicProxy`, which only reaches | |
| 52 | // public addresses, so a page cannot reach this machine's local network. The | |
| 53 | // main frame also stays on the page and the browser opens no popups, so a | |
| 54 | // capture always shows the page itself. | |
| 55 | const PAGE_ORIGIN = "http://t3-page.localhost"; | |
| 56 | const PAGE_URL = `${PAGE_ORIGIN}/page.html`; | |
| 57 | // Stack traces and load errors name the page this way instead of its URL. | |
| 58 | const PAGE_NAME = "page.html"; | |
| 59 | // Each measuring load reads its own copy of the page off the pipe. | |
| 60 | const MEASURE_CONCURRENCY = 3; | |
| 61 | ||
| 62 | const textEncoder = new TextEncoder(); | |
| 63 | ||
| 64 | /** | |
| 65 | * A page as the base64 bytes `PAGE_URL` serves, encoded once however often it | |
| 66 | * loads. Node's encoder, since pages run to 25 MiB. | |
| 67 | */ | |
| 68 | const pageBody = (html: string) => | |
| 69 | Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); | |
| 70 | // What Chrome prints before aborting when it cannot sandbox itself, e.g. on | |
| 71 | // Ubuntu 23.10+ where AppArmor restricts unprivileged user namespaces. | |
| 72 | const NO_SANDBOX_SIGNATURE = "No usable sandbox"; | |
| 73 | ||
| 74 | const CdpMessage = Schema.fromJsonString( | |
| 75 | Schema.Struct({ | |
| 76 | id: Schema.optional(Schema.Number), | |
| 77 | method: Schema.optional(Schema.String), | |
| 78 | sessionId: Schema.optional(Schema.String), | |
| 79 | params: Schema.optional(Schema.Unknown), | |
| 80 | result: Schema.optional(Schema.Unknown), | |
| 81 | error: Schema.optional(Schema.Struct({ message: Schema.String })), | |
| 82 | }), | |
| 83 | ); | |
| 84 | const decodeCdpMessage = Schema.decodeUnknownOption(CdpMessage); | |
| 85 | const encodeCdpCommand = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 86 | const RemoteObject = Schema.Struct({ | |
| 87 | type: Schema.String, | |
| 88 | value: Schema.optional(Schema.Unknown), | |
| 89 | description: Schema.optional(Schema.String), | |
| 90 | }); | |
| 91 | const decodeConsoleApiCalled = Schema.decodeUnknownOption( | |
| 92 | Schema.Struct({ type: Schema.String, args: Schema.Array(RemoteObject) }), | |
| 93 | ); | |
| 94 | const decodeExceptionThrown = Schema.decodeUnknownOption( | |
| 95 | Schema.Struct({ | |
| 96 | exceptionDetails: Schema.Struct({ | |
| 97 | text: Schema.String, | |
| 98 | exception: Schema.optional(RemoteObject), | |
| 99 | }), | |
| 100 | }), | |
| 101 | ); | |
| 102 | const decodeRequestPaused = Schema.decodeUnknownOption( | |
| 103 | Schema.Struct({ | |
| 104 | requestId: Schema.String, | |
| 105 | request: Schema.Struct({ url: Schema.String }), | |
| 106 | frameId: Schema.optional(Schema.String), | |
| 107 | resourceType: Schema.optional(Schema.String), | |
| 108 | }), | |
| 109 | ); | |
| 110 | const decodeLogEntryAdded = Schema.decodeUnknownOption( | |
| 111 | Schema.Struct({ | |
| 112 | entry: Schema.Struct({ | |
| 113 | level: Schema.String, | |
| 114 | text: Schema.String, | |
| 115 | url: Schema.optional(Schema.String), | |
| 116 | }), | |
| 117 | }), | |
| 118 | ); | |
| 119 | ||
| 120 | const remoteObjectText = (value: typeof RemoteObject.Type) => | |
| 121 | typeof value.value === "string" | |
| 122 | ? value.value | |
| 123 | : (value.description ?? (value.value === undefined ? value.type : String(value.value))); | |
| 124 | ||
| 125 | const CONSOLE_LEVELS: Readonly<Record<string, ConsoleMessage["level"]>> = { | |
| 126 | log: "log", | |
| 127 | debug: "log", | |
| 128 | dir: "log", | |
| 129 | dirxml: "log", | |
| 130 | table: "log", | |
| 131 | trace: "log", | |
| 132 | info: "info", | |
| 133 | warning: "warning", | |
| 134 | error: "error", | |
| 135 | assert: "error", | |
| 136 | }; | |
| 137 | ||
| 138 | /** Everything the page logs, uncaught exceptions, and the browser's own load errors. */ | |
| 139 | const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessage | undefined => { | |
| 140 | if (method === "Runtime.consoleAPICalled") { | |
| 141 | const event = Option.getOrUndefined(decodeConsoleApiCalled(params)); | |
| 142 | const level = event === undefined ? undefined : CONSOLE_LEVELS[event.type]; | |
| 143 | return event && level ? { level, text: event.args.map(remoteObjectText).join(" ") } : undefined; | |
| 144 | } | |
| 145 | if (method === "Runtime.exceptionThrown") { | |
| 146 | const details = Option.getOrUndefined(decodeExceptionThrown(params))?.exceptionDetails; | |
| 147 | return details | |
| 148 | ? { level: "error", text: details.exception?.description ?? details.text } | |
| 149 | : undefined; | |
| 150 | } | |
| 151 | if (method === "Log.entryAdded") { | |
| 152 | const entry = Option.getOrUndefined(decodeLogEntryAdded(params))?.entry; | |
| 153 | return entry && (entry.level === "error" || entry.level === "warning") | |
| 154 | ? { level: entry.level, text: entry.url ? `${entry.text} ${entry.url}` : entry.text } | |
| 155 | : undefined; | |
| 156 | } | |
| 157 | return undefined; | |
| 158 | }; | |
| 159 | ||
| 160 | // Resolves after web fonts load and two frames paint, so late layout lands in the capture. | |
| 161 | const SETTLE_EXPRESSION = | |
| 162 | "document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"; | |
| 163 | // The root's scroll height never drops below the viewport, so a short page | |
| 164 | // reports its own box height instead. | |
| 165 | const MEASURE_EXPRESSION = | |
| 166 | "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; | |
| 167 | ||
| 168 | const Ignored = Schema.Unknown; | |
| 169 | const Navigation = Schema.Struct({ errorText: Schema.optional(Schema.String) }); | |
| 170 | const Measured = Schema.Struct({ result: Schema.Struct({ value: Schema.Finite }) }); | |
| 171 | ||
| 172 | interface PageEvents { | |
| 173 | /** The page's main frame, which only ever shows `PAGE_URL`. */ | |
| 174 | readonly mainFrameId: string; | |
| 175 | /** The page served for `PAGE_URL`, from `pageBody`. */ | |
| 176 | body: Uint8Array | undefined; | |
| 177 | loaded: Deferred.Deferred<void, BrowserFailure> | undefined; | |
| 178 | readonly consoleMessages: Array<ConsoleMessage>; | |
| 179 | omittedConsoleMessages: number; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Starts the headless shell for the life of the scope, speaking CDP over | |
| 184 | * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). Scope | |
| 185 | * close kills the process group. Each page is its own target, so pages load | |
| 186 | * in parallel. | |
| 187 | */ | |
| 188 | const launchBrowser = Effect.fnUntraced(function* (input: { | |
| 189 | readonly executable: string; | |
| 190 | readonly noSandbox: boolean; | |
| 191 | readonly profileDirectory: string; | |
| 192 | }) { | |
| 193 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 194 | const proxyPort = yield* publicProxy.pipe( | |
| 195 | Effect.mapError( | |
| 196 | (cause) => new HtmlRenderBrowserError({ reason: "the preview proxy could not start", cause }), | |
| 197 | ), | |
| 198 | ); | |
| 199 | const outgoing = yield* Queue.unbounded<Uint8Array>(); | |
| 200 | const child = yield* spawner | |
| 201 | .spawn( | |
| 202 | ChildProcess.make( | |
| 203 | input.executable, | |
| 204 | [ | |
| 205 | "--headless=new", | |
| 206 | "--remote-debugging-pipe", | |
| 207 | "--no-first-run", | |
| 208 | "--no-default-browser-check", | |
| 209 | "--disable-gpu", | |
| 210 | "--hide-scrollbars", | |
| 211 | "--mute-audio", | |
| 212 | "--block-new-web-contents", | |
| 213 | `--proxy-server=socks5://127.0.0.1:${proxyPort}`, | |
| 214 | // Loopback would otherwise skip the proxy. | |
| 215 | "--proxy-bypass-list=<-loopback>", | |
| 216 | // WebRTC would otherwise send UDP, which no proxy carries. | |
| 217 | "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", | |
| 218 | ...(input.noSandbox ? ["--no-sandbox"] : []), | |
| 219 | `--user-data-dir=${input.profileDirectory}`, | |
| 220 | "about:blank", | |
| 221 | ], | |
| 222 | { | |
| 223 | stdin: "ignore", | |
| 224 | stdout: "ignore", | |
| 225 | stderr: "pipe", | |
| 226 | forceKillAfter: "2 seconds", | |
| 227 | additionalFds: { | |
| 228 | fd3: { type: "input", stream: Stream.fromQueue(outgoing) }, | |
| 229 | fd4: { type: "output" }, | |
| 230 | }, | |
| 231 | }, | |
| 232 | ), | |
| 233 | ) | |
| 234 | .pipe( | |
| 235 | Effect.mapError( | |
| 236 | (cause) => | |
| 237 | new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), | |
| 238 | ), | |
| 239 | ); | |
| 240 | ||
| 241 | let stderrTail = ""; | |
| 242 | let sandboxUnavailable = false; | |
| 243 | const stderrReader = yield* child.stderr.pipe( | |
| 244 | Stream.decodeText(), | |
| 245 | Stream.runForEach((text) => | |
| 246 | Effect.sync(() => { | |
| 247 | stderrTail = (stderrTail + text).slice(-1_024); | |
| 248 | if (stderrTail.includes(NO_SANDBOX_SIGNATURE)) sandboxUnavailable = true; | |
| 249 | }), | |
| 250 | ), | |
| 251 | Effect.ignore, | |
| 252 | Effect.forkScoped, | |
| 253 | ); | |
| 254 | ||
| 255 | const pending = new Map< | |
| 256 | number, | |
| 257 | { readonly method: string; readonly reply: Deferred.Deferred<unknown, BrowserFailure> } | |
| 258 | >(); | |
| 259 | const pages = new Map<string, PageEvents>(); | |
| 260 | let disconnected: BrowserFailure | undefined; | |
| 261 | let nextId = 0; | |
| 262 | ||
| 263 | // A command whose reply nobody awaits; `receive` drops replies without a waiter. | |
| 264 | const post = (method: string, params: Record<string, unknown>, sessionId: string) => | |
| 265 | Queue.offer( | |
| 266 | outgoing, | |
| 267 | new TextEncoder().encode( | |
| 268 | `${encodeCdpCommand({ id: ++nextId, method, params, sessionId })}\0`, | |
| 269 | ), | |
| 270 | ).pipe(Effect.asVoid); | |
| 271 | ||
| 272 | // Serves the page. Its body is shared bytes between two small JSON halves, | |
| 273 | // queued together, so loading a page of up to 25 MiB at several widths never | |
| 274 | // copies it on this side. | |
| 275 | const fulfillPage = (sessionId: string, requestId: string, body: Uint8Array) => | |
| 276 | Queue.offerAll(outgoing, [ | |
| 277 | textEncoder.encode( | |
| 278 | `{"id":${++nextId},"sessionId":${JSON.stringify(sessionId)},"method":"Fetch.fulfillRequest","params":{"requestId":${JSON.stringify(requestId)},"responseCode":200,"responseHeaders":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"body":"`, | |
| 279 | ), | |
| 280 | body, | |
| 281 | textEncoder.encode('"}}\0'), | |
| 282 | ]).pipe(Effect.asVoid); | |
| 283 | ||
| 284 | const receive = (raw: string) => { | |
| 285 | const message = Option.getOrUndefined(decodeCdpMessage(raw)); | |
| 286 | if (message?.id !== undefined) { | |
| 287 | const waiter = pending.get(message.id); | |
| 288 | pending.delete(message.id); | |
| 289 | if (!waiter) return Effect.void; | |
| 290 | return message.error | |
| 291 | ? Deferred.fail( | |
| 292 | waiter.reply, | |
| 293 | new HtmlRenderBrowserError({ | |
| 294 | reason: `${waiter.method} failed`, | |
| 295 | cause: message.error.message, | |
| 296 | }), | |
| 297 | ) | |
| 298 | : Deferred.succeed(waiter.reply, message.result); | |
| 299 | } | |
| 300 | const sessionId = message?.sessionId; | |
| 301 | const page = sessionId === undefined ? undefined : pages.get(sessionId); | |
| 302 | if (sessionId === undefined || !page || !message?.method) return Effect.void; | |
| 303 | if (message.method === "Page.loadEventFired") { | |
| 304 | return page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void; | |
| 305 | } | |
| 306 | if (message.method === "Fetch.requestPaused") { | |
| 307 | const paused = Option.getOrUndefined(decodeRequestPaused(message.params)); | |
| 308 | if (!paused) return Effect.void; | |
| 309 | const body = page.body; | |
| 310 | const url = paused.request.url.split("#", 1)[0]!; | |
| 311 | if (url === PAGE_URL && body !== undefined) { | |
| 312 | return fulfillPage(sessionId, paused.requestId, body); | |
| 313 | } | |
| 314 | // A frame inside the page may show another site; Local Network Access | |
| 315 | // still covers it. The main frame and T3's own origin serve nothing else. | |
| 316 | const otherSiteFrame = | |
| 317 | paused.resourceType === "Document" && | |
| 318 | paused.frameId !== page.mainFrameId && | |
| 319 | !url.startsWith(`${PAGE_ORIGIN}/`); | |
| 320 | return otherSiteFrame | |
| 321 | ? post("Fetch.continueRequest", { requestId: paused.requestId }, sessionId) | |
| 322 | : post( | |
| 323 | "Fetch.failRequest", | |
| 324 | { requestId: paused.requestId, errorReason: "AccessDenied" }, | |
| 325 | sessionId, | |
| 326 | ); | |
| 327 | } | |
| 328 | const consoleMessage = consoleMessageFromEvent(message.method, message.params); | |
| 329 | if (!consoleMessage) return Effect.void; | |
| 330 | if (page.consoleMessages.length >= MAX_CONSOLE_MESSAGES) { | |
| 331 | page.omittedConsoleMessages += 1; | |
| 332 | return Effect.void; | |
| 333 | } | |
| 334 | const text = consoleMessage.text.replaceAll(PAGE_URL, PAGE_NAME); | |
| 335 | page.consoleMessages.push({ | |
| 336 | level: consoleMessage.level, | |
| 337 | text: | |
| 338 | text.length > MAX_CONSOLE_TEXT_CHARS ? `${text.slice(0, MAX_CONSOLE_TEXT_CHARS)}…` : text, | |
| 339 | }); | |
| 340 | return Effect.void; | |
| 341 | }; | |
| 342 | ||
| 343 | // The pipe closes when the browser exits. A sandbox abort says why on | |
| 344 | // stderr, which may still be draining, so give it a moment. | |
| 345 | const disconnect = Effect.gen(function* () { | |
| 346 | yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore); | |
| 347 | const error = sandboxUnavailable | |
| 348 | ? new HtmlRenderSandboxUnavailableError() | |
| 349 | : new HtmlRenderBrowserError({ reason: "the browser exited unexpectedly" }); | |
| 350 | disconnected = error; | |
| 351 | const waiters = [...pending.values()]; | |
| 352 | pending.clear(); | |
| 353 | yield* Effect.forEach(waiters, ({ reply }) => Deferred.fail(reply, error), { discard: true }); | |
| 354 | yield* Effect.forEach( | |
| 355 | pages.values(), | |
| 356 | (page) => (page.loaded ? Deferred.fail(page.loaded, error) : Effect.void), | |
| 357 | { discard: true }, | |
| 358 | ); | |
| 359 | }); | |
| 360 | ||
| 361 | // Decoded text chunks may split a message anywhere, and a screenshot reply spans many. | |
| 362 | let partial: Array<string> = []; | |
| 363 | yield* child.getOutputFd(4).pipe( | |
| 364 | Stream.decodeText(), | |
| 365 | Stream.runForEach((text) => { | |
| 366 | const complete: Array<string> = []; | |
| 367 | let start = 0; | |
| 368 | for (let end = text.indexOf("\0"); end !== -1; end = text.indexOf("\0", start)) { | |
| 369 | partial.push(text.slice(start, end)); | |
| 370 | complete.push(partial.join("")); | |
| 371 | partial = []; | |
| 372 | start = end + 1; | |
| 373 | } | |
| 374 | if (start < text.length) partial.push(text.slice(start)); | |
| 375 | return Effect.forEach(complete, receive, { discard: true }); | |
| 376 | }), | |
| 377 | Effect.ignore, | |
| 378 | // Interruption means the scope is closing on purpose; only an exit disconnects. | |
| 379 | Effect.andThen(disconnect), | |
| 380 | Effect.forkScoped, | |
| 381 | ); | |
| 382 | ||
| 383 | const send = <A>( | |
| 384 | method: string, | |
| 385 | params: Record<string, unknown>, | |
| 386 | result: Schema.Decoder<A>, | |
| 387 | sessionId?: string, | |
| 388 | ) => | |
| 389 | Effect.gen(function* () { | |
| 390 | if (disconnected) return yield* disconnected; | |
| 391 | const id = ++nextId; | |
| 392 | const reply = yield* Deferred.make<unknown, BrowserFailure>(); | |
| 393 | pending.set(id, { method, reply }); | |
| 394 | const message = encodeCdpCommand({ id, method, params, ...(sessionId ? { sessionId } : {}) }); | |
| 395 | yield* Queue.offer(outgoing, new TextEncoder().encode(`${message}\0`)); | |
| 396 | return yield* Deferred.await(reply).pipe( | |
| 397 | Effect.flatMap(Schema.decodeUnknownEffect(result)), | |
| 398 | Effect.mapError((error) => | |
| 399 | error._tag === "HtmlRenderBrowserError" || | |
| 400 | error._tag === "HtmlRenderSandboxUnavailableError" | |
| 401 | ? error | |
| 402 | : new HtmlRenderBrowserError({ | |
| 403 | reason: `${method} returned an unexpected result`, | |
| 404 | cause: error, | |
| 405 | }), | |
| 406 | ), | |
| 407 | ); | |
| 408 | }); | |
| 409 | ||
| 410 | /** A fresh page at `width`; each `load` navigates it and measures the settled layout. */ | |
| 411 | const openPage = Effect.fnUntraced(function* (width: number) { | |
| 412 | const { targetId } = yield* send( | |
| 413 | "Target.createTarget", | |
| 414 | { url: "about:blank" }, | |
| 415 | Schema.Struct({ targetId: Schema.String }), | |
| 416 | ); | |
| 417 | const { sessionId } = yield* send( | |
| 418 | "Target.attachToTarget", | |
| 419 | { targetId, flatten: true }, | |
| 420 | Schema.Struct({ sessionId: Schema.String }), | |
| 421 | ); | |
| 422 | const events: PageEvents = { | |
| 423 | // A page target's id is its main frame's id. | |
| 424 | mainFrameId: targetId, | |
| 425 | body: undefined, | |
| 426 | loaded: undefined, | |
| 427 | consoleMessages: [], | |
| 428 | omittedConsoleMessages: 0, | |
| 429 | }; | |
| 430 | pages.set(sessionId, events); | |
| 431 | yield* send("Page.enable", {}, Ignored, sessionId); | |
| 432 | yield* send("Runtime.enable", {}, Ignored, sessionId); | |
| 433 | yield* send("Log.enable", {}, Ignored, sessionId); | |
| 434 | // Previews have no use for WebRTC, whose ICE servers can make the browser | |
| 435 | // resolve names outside the proxy. Runs before page scripts in every frame. | |
| 436 | yield* send( | |
| 437 | "Page.addScriptToEvaluateOnNewDocument", | |
| 438 | { | |
| 439 | source: "delete window.RTCPeerConnection; delete window.webkitRTCPeerConnection;", | |
| 440 | runImmediately: true, | |
| 441 | }, | |
| 442 | Ignored, | |
| 443 | sessionId, | |
| 444 | ); | |
| 445 | // Pauses T3's own origin, to serve the page, and every document, to keep | |
| 446 | // the main frame on it. | |
| 447 | yield* send( | |
| 448 | "Fetch.enable", | |
| 449 | { | |
| 450 | patterns: [ | |
| 451 | { urlPattern: `${PAGE_ORIGIN}/*` }, | |
| 452 | { urlPattern: "*", resourceType: "Document" }, | |
| 453 | ], | |
| 454 | }, | |
| 455 | Ignored, | |
| 456 | sessionId, | |
| 457 | ); | |
| 458 | yield* send( | |
| 459 | "Emulation.setDeviceMetricsOverride", | |
| 460 | { width, height: VIEWPORT_HEIGHT, deviceScaleFactor: 1, mobile: false }, | |
| 461 | Ignored, | |
| 462 | sessionId, | |
| 463 | ); | |
| 464 | ||
| 465 | /** Loads a page from `pageBody`, so callers loading it often encode it once. */ | |
| 466 | const load = Effect.fnUntraced(function* (body: Uint8Array, urlFragment: string) { | |
| 467 | const loaded = yield* Deferred.make<void, BrowserFailure>(); | |
| 468 | events.body = body; | |
| 469 | events.loaded = loaded; | |
| 470 | const navigation = yield* send( | |
| 471 | "Page.navigate", | |
| 472 | { url: `${PAGE_URL}${urlFragment}` }, | |
| 473 | Navigation, | |
| 474 | sessionId, | |
| 475 | ); | |
| 476 | if (navigation.errorText) { | |
| 477 | return yield* new HtmlRenderBrowserError({ reason: "the page could not be opened" }); | |
| 478 | } | |
| 479 | yield* Deferred.await(loaded); | |
| 480 | yield* send( | |
| 481 | "Runtime.evaluate", | |
| 482 | { expression: SETTLE_EXPRESSION, awaitPromise: true }, | |
| 483 | Ignored, | |
| 484 | sessionId, | |
| 485 | ); | |
| 486 | const measured = yield* send( | |
| 487 | "Runtime.evaluate", | |
| 488 | { expression: MEASURE_EXPRESSION, returnByValue: true }, | |
| 489 | Measured, | |
| 490 | sessionId, | |
| 491 | ); | |
| 492 | return Math.max(0, Math.ceil(measured.result.value)); | |
| 493 | }); | |
| 494 | ||
| 495 | const screenshot = (height: number) => | |
| 496 | send( | |
| 497 | "Page.captureScreenshot", | |
| 498 | { | |
| 499 | format: "png", | |
| 500 | captureBeyondViewport: true, | |
| 501 | clip: { x: 0, y: 0, width, height, scale: 1 }, | |
| 502 | }, | |
| 503 | Schema.Struct({ data: Schema.String }), | |
| 504 | sessionId, | |
| 505 | ).pipe(Effect.map(({ data }) => data)); | |
| 506 | ||
| 507 | const consoleMessages = (): ReadonlyArray<ConsoleMessage> => | |
| 508 | events.omittedConsoleMessages === 0 | |
| 509 | ? [...events.consoleMessages] | |
| 510 | : [ | |
| 511 | ...events.consoleMessages, | |
| 512 | { | |
| 513 | level: "warning", | |
| 514 | text: `${events.omittedConsoleMessages} more console messages were omitted.`, | |
| 515 | }, | |
| 516 | ]; | |
| 517 | ||
| 518 | // Frees the page in the browser once it is no longer needed. | |
| 519 | const close = send("Target.closeTarget", { targetId }, Ignored).pipe( | |
| 520 | Effect.ensuring(Effect.sync(() => pages.delete(sessionId))), | |
| 521 | Effect.ignore, | |
| 522 | ); | |
| 523 | ||
| 524 | return { load, screenshot, consoleMessages, close }; | |
| 525 | }); | |
| 526 | ||
| 527 | return { openPage }; | |
| 528 | }); | |
| 529 | ||
| 530 | /** A temporary directory for the browser profile, removed with the scope. */ | |
| 531 | const scratchDirectory = Effect.gen(function* () { | |
| 532 | const fileSystem = yield* FileSystem.FileSystem; | |
| 533 | return yield* Effect.acquireRelease( | |
| 534 | fileSystem.makeTempDirectory({ prefix: "t3-html-preview-" }), | |
| 535 | (directory) => fileSystem.remove(directory, { recursive: true }).pipe(Effect.ignore), | |
| 536 | ).pipe( | |
| 537 | Effect.mapError( | |
| 538 | (cause) => | |
| 539 | new HtmlRenderBrowserError({ reason: "a temporary directory could not be created", cause }), | |
| 540 | ), | |
| 541 | ); | |
| 542 | }); | |
| 543 | ||
| 544 | /** Loads `html` at `width` and returns a PNG of the top of the page. */ | |
| 545 | export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreenshot")( | |
| 546 | function* (input: { | |
| 547 | readonly executable: string; | |
| 548 | readonly noSandbox: boolean; | |
| 549 | readonly html: string; | |
| 550 | readonly width: number; | |
| 551 | readonly urlFragment: string; | |
| 552 | }) { | |
| 553 | const path = yield* Path.Path; | |
| 554 | const directory = yield* scratchDirectory; | |
| 555 | const browser = yield* launchBrowser({ | |
| 556 | executable: input.executable, | |
| 557 | noSandbox: input.noSandbox, | |
| 558 | profileDirectory: path.join(directory, "profile"), | |
| 559 | }); | |
| 560 | const page = yield* browser.openPage(input.width); | |
| 561 | const contentHeight = yield* page.load(pageBody(input.html), input.urlFragment); | |
| 562 | const capturedHeight = Math.max(1, Math.min(contentHeight, MAX_CAPTURE_HEIGHT)); | |
| 563 | const png = yield* page.screenshot(capturedHeight); | |
| 564 | return { png, contentHeight, capturedHeight, consoleMessages: page.consoleMessages() }; | |
| 565 | }, | |
| 566 | Effect.scoped, | |
| 567 | Effect.timeoutOrElse({ | |
| 568 | duration: CAPTURE_TIMEOUT, | |
| 569 | orElse: () => | |
| 570 | Effect.fail( | |
| 571 | new HtmlRenderBrowserError({ | |
| 572 | reason: `the page did not finish loading within ${CAPTURE_TIMEOUT}`, | |
| 573 | }), | |
| 574 | ), | |
| 575 | }), | |
| 576 | ); | |
| 577 | ||
| 578 | /** | |
| 579 | * Content heights of the page at each width, each from a fresh load, since | |
| 580 | * pages often lay themselves out from the width once at load. One browser, | |
| 581 | * a few widths at a time. | |
| 582 | */ | |
| 583 | export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: { | |
| 584 | readonly executable: string; | |
| 585 | readonly noSandbox: boolean; | |
| 586 | readonly html: string; | |
| 587 | readonly widths: ReadonlyArray<number>; | |
| 588 | readonly urlFragment: string; | |
| 589 | }) { | |
| 590 | const path = yield* Path.Path; | |
| 591 | const directory = yield* scratchDirectory; | |
| 592 | const browser = yield* launchBrowser({ | |
| 593 | executable: input.executable, | |
| 594 | noSandbox: input.noSandbox, | |
| 595 | profileDirectory: path.join(directory, "profile"), | |
| 596 | }); | |
| 597 | const body = pageBody(input.html); | |
| 598 | return yield* Effect.forEach( | |
| 599 | input.widths, | |
| 600 | (width) => | |
| 601 | browser.openPage(width).pipe( | |
| 602 | Effect.flatMap((page) => | |
| 603 | page.load(body, input.urlFragment).pipe(Effect.ensuring(page.close)), | |
| 604 | ), | |
| 605 | Effect.map((height) => [width, height] as const), | |
| 606 | ), | |
| 607 | { concurrency: MEASURE_CONCURRENCY }, | |
| 608 | ); | |
| 609 | }, Effect.scoped); |
apps/server/src/htmlRender/publicProxy.test.ts created+186| ... | ... | @@ -0,0 +1,186 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - raw sockets speak SOCKS5 to the proxy. | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import { vi } from "vite-plus/test"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as NodeNet from "node:net"; | |
| 6 | import * as NodeOS from "node:os"; | |
| 7 | ||
| 8 | import { publicProxy } from "./publicProxy.ts"; | |
| 9 | ||
| 10 | // A public IPv4 address this machine holds, which no private range covers. | |
| 11 | const OWN_PUBLIC_IPV4 = "198.51.100.7"; | |
| 12 | vi.mock("node:os", async (importOriginal) => { | |
| 13 | const actual = await importOriginal<typeof NodeOS>(); | |
| 14 | return { | |
| 15 | ...actual, | |
| 16 | networkInterfaces: () => ({ | |
| 17 | ...actual.networkInterfaces(), | |
| 18 | "t3-test": [ | |
| 19 | { | |
| 20 | address: OWN_PUBLIC_IPV4, | |
| 21 | netmask: "255.255.255.0", | |
| 22 | family: "IPv4", | |
| 23 | mac: "00:00:00:00:00:00", | |
| 24 | internal: false, | |
| 25 | cidr: `${OWN_PUBLIC_IPV4}/24`, | |
| 26 | }, | |
| 27 | ], | |
| 28 | }), | |
| 29 | }; | |
| 30 | }); | |
| 31 | ||
| 32 | /** Sends a SOCKS5 greeting and CONNECT, and resolves with the reply code. */ | |
| 33 | const connectThrough = (proxyPort: number, target: Buffer, version = 5) => | |
| 34 | Effect.callback<{ readonly code: number; readonly socket: NodeNet.Socket }>((resume) => { | |
| 35 | const socket = NodeNet.connect(proxyPort, "127.0.0.1", () => { | |
| 36 | socket.write(Buffer.from([5, 1, 0])); | |
| 37 | socket.write(Buffer.concat([Buffer.from([version, 1, 0]), target])); | |
| 38 | }); | |
| 39 | // The proxy may reset a refused connection. | |
| 40 | socket.on("error", () => {}); | |
| 41 | let received = Buffer.alloc(0); | |
| 42 | socket.on("data", (chunk) => { | |
| 43 | received = Buffer.concat([received, chunk]); | |
| 44 | // Method selection (2 bytes) then the 10-byte reply. | |
| 45 | if (received.length >= 12) resume(Effect.succeed({ code: received[3]!, socket })); | |
| 46 | }); | |
| 47 | socket.on("close", () => resume(Effect.succeed({ code: received[3] ?? -1, socket }))); | |
| 48 | }); | |
| 49 | ||
| 50 | const ipv4Target = (address: string, port: number) => { | |
| 51 | const target = Buffer.alloc(7); | |
| 52 | target[0] = 1; | |
| 53 | address.split(".").forEach((octet, index) => (target[1 + index] = Number(octet))); | |
| 54 | target.writeUInt16BE(port, 5); | |
| 55 | return target; | |
| 56 | }; | |
| 57 | ||
| 58 | const ipv6Target = (address: string, port: number) => { | |
| 59 | const target = Buffer.alloc(19); | |
| 60 | target[0] = 4; | |
| 61 | const [head = "", tail = ""] = address.split("::"); | |
| 62 | const left = head ? head.split(":") : []; | |
| 63 | const right = tail ? tail.split(":") : []; | |
| 64 | const groups = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right]; | |
| 65 | groups.forEach((group, index) => target.writeUInt16BE(Number.parseInt(group, 16), 1 + index * 2)); | |
| 66 | target.writeUInt16BE(port, 17); | |
| 67 | return target; | |
| 68 | }; | |
| 69 | ||
| 70 | /** `a.b.c.d` as the two hex groups of an IPv4-mapped IPv6 address. */ | |
| 71 | const toHexPair = (address: string) => { | |
| 72 | const [a = 0, b = 0, c = 0, d = 0] = address.split(".").map(Number); | |
| 73 | return `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; | |
| 74 | }; | |
| 75 | ||
| 76 | const domainTarget = (host: string, port: number) => { | |
| 77 | const name = Buffer.from(host, "latin1"); | |
| 78 | const target = Buffer.alloc(4 + name.length); | |
| 79 | target[0] = 3; | |
| 80 | target[1] = name.length; | |
| 81 | name.copy(target, 2); | |
| 82 | target.writeUInt16BE(port, 2 + name.length); | |
| 83 | return target; | |
| 84 | }; | |
| 85 | ||
| 86 | describe("publicProxy", () => { | |
| 87 | it.effect("refuses loopback and private targets, by address or by name", () => | |
| 88 | Effect.gen(function* () { | |
| 89 | const port = yield* publicProxy; | |
| 90 | for (const target of [ | |
| 91 | ipv4Target("127.0.0.1", 80), | |
| 92 | ipv4Target("10.1.2.3", 80), | |
| 93 | ipv4Target("169.254.169.254", 80), | |
| 94 | ipv4Target("198.18.0.1", 80), | |
| 95 | domainTarget("localhost", 80), | |
| 96 | // IPv6 forms that carry a local IPv4 address: NAT64, 6to4, Teredo, | |
| 97 | // and IPv4-compatible. | |
| 98 | ipv6Target("64:ff9b::7f00:1", 80), | |
| 99 | ipv6Target("64:ff9b::a00:1", 80), | |
| 100 | ipv6Target("2002:7f00:1::1", 80), | |
| 101 | ipv6Target("2001::1", 80), | |
| 102 | ipv6Target("::7f00:1", 80), | |
| 103 | ]) { | |
| 104 | const { code, socket } = yield* connectThrough(port, target); | |
| 105 | socket.destroy(); | |
| 106 | expect(code).toBe(2); | |
| 107 | } | |
| 108 | // Port 0 is not a connection target. | |
| 109 | const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 0)); | |
| 110 | socket.destroy(); | |
| 111 | expect(code).toBe(7); | |
| 112 | }).pipe(Effect.scoped), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("refuses every address this machine holds, even a public one", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | const port = yield* publicProxy; | |
| 118 | const own = Object.values(NodeOS.networkInterfaces()) | |
| 119 | .flatMap((entries) => entries ?? []) | |
| 120 | .filter((entry) => !entry.internal && !entry.address.startsWith("fe80")); | |
| 121 | // IPv4 also as IPv4-mapped IPv6, which names the same host. | |
| 122 | const targets = own.flatMap((entry) => | |
| 123 | entry.family === "IPv6" | |
| 124 | ? [ipv6Target(entry.address, 80)] | |
| 125 | : [ | |
| 126 | ipv4Target(entry.address, 80), | |
| 127 | ipv6Target(`::ffff:${toHexPair(entry.address)}`, 80), | |
| 128 | ipv6Target(`64:ff9b::${toHexPair(entry.address)}`, 80), | |
| 129 | ], | |
| 130 | ); | |
| 131 | for (const target of targets) { | |
| 132 | const { code, socket } = yield* connectThrough(port, target); | |
| 133 | socket.destroy(); | |
| 134 | expect(code).toBe(2); | |
| 135 | } | |
| 136 | }).pipe(Effect.scoped), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("refuses a request with a wrong version byte", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const port = yield* publicProxy; | |
| 142 | const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 443), 4); | |
| 143 | socket.destroy(); | |
| 144 | expect(code).toBe(7); | |
| 145 | }).pipe(Effect.scoped), | |
| 146 | ); | |
| 147 | ||
| 148 | it.effect("fails instead of crashing when it cannot listen", () => | |
| 149 | Effect.gen(function* () { | |
| 150 | const exhausted = Object.assign(new Error("too many open files"), { code: "EMFILE" }); | |
| 151 | const listen = vi.spyOn(NodeNet.Server.prototype, "listen").mockImplementationOnce(function ( | |
| 152 | this: NodeNet.Server, | |
| 153 | ) { | |
| 154 | process.nextTick(() => this.emit("error", exhausted)); | |
| 155 | return this; | |
| 156 | }); | |
| 157 | const error = yield* publicProxy.pipe(Effect.flip, Effect.scoped); | |
| 158 | listen.mockRestore(); | |
| 159 | expect(error).toBe(exhausted); | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | it.effect("closes every connection when its scope closes", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const socket = yield* Effect.scoped( | |
| 166 | Effect.gen(function* () { | |
| 167 | const port = yield* publicProxy; | |
| 168 | return yield* Effect.callback<NodeNet.Socket>((resume) => { | |
| 169 | // A client mid-handshake, which the proxy must not wait on; closing | |
| 170 | // resets it. | |
| 171 | const client = NodeNet.connect(port, "127.0.0.1", () => { | |
| 172 | client.write(Buffer.from([5, 1, 0])); | |
| 173 | resume(Effect.succeed(client)); | |
| 174 | }); | |
| 175 | client.on("error", () => {}); | |
| 176 | }); | |
| 177 | }), | |
| 178 | ); | |
| 179 | yield* Effect.callback<void>((resume) => { | |
| 180 | if (socket.closed) return resume(Effect.void); | |
| 181 | socket.once("close", () => resume(Effect.void)); | |
| 182 | }); | |
| 183 | expect(socket.closed).toBe(true); | |
| 184 | }), | |
| 185 | ); | |
| 186 | }); |
apps/server/src/htmlRender/publicProxy.ts created+244| ... | ... | @@ -0,0 +1,244 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Effect has no SOCKS proxy or address block list. | |
| 2 | import * as NodeDnsPromises from "node:dns/promises"; | |
| 3 | import * as NodeNet from "node:net"; | |
| 4 | import * as NodeOS from "node:os"; | |
| 5 | ||
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | ||
| 8 | /** This machine and its local networks, which HTML previews must never reach. */ | |
| 9 | const LOCAL_ADDRESSES = new NodeNet.BlockList(); | |
| 10 | for (const [network, prefix] of [ | |
| 11 | ["0.0.0.0", 8], | |
| 12 | ["10.0.0.0", 8], | |
| 13 | ["100.64.0.0", 10], | |
| 14 | ["127.0.0.0", 8], | |
| 15 | ["169.254.0.0", 16], | |
| 16 | ["172.16.0.0", 12], | |
| 17 | ["192.168.0.0", 16], | |
| 18 | ["198.18.0.0", 15], | |
| 19 | ["224.0.0.0", 3], | |
| 20 | ] as const) { | |
| 21 | LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4"); | |
| 22 | } | |
| 23 | // Also the IPv6 ranges that carry an IPv4 address a translator may route to | |
| 24 | // without checking it: IPv4-compatible, local-use NAT64, and Teredo. | |
| 25 | for (const [network, prefix] of [ | |
| 26 | ["::", 96], | |
| 27 | ["64:ff9b:1::", 48], | |
| 28 | ["2001::", 32], | |
| 29 | ["fc00::", 7], | |
| 30 | ["fe80::", 10], | |
| 31 | ["ff00::", 8], | |
| 32 | ] as const) { | |
| 33 | LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv6"); | |
| 34 | } | |
| 35 | ||
| 36 | const NAT64 = new NodeNet.BlockList(); | |
| 37 | NAT64.addSubnet("64:ff9b::", 96, "ipv6"); | |
| 38 | const SIX_TO_FOUR = new NodeNet.BlockList(); | |
| 39 | SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6"); | |
| 40 | ||
| 41 | /** The sixteen-bit groups of an IPv6 address, which may end in dotted IPv4. */ | |
| 42 | const ipv6Groups = (address: string) => { | |
| 43 | const bare = address.split("%", 1)[0]!; | |
| 44 | const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(bare); | |
| 45 | const text = dotted | |
| 46 | ? `${bare.slice(0, dotted.index)}${((+dotted[1]! << 8) | +dotted[2]!).toString(16)}:${((+dotted[3]! << 8) | +dotted[4]!).toString(16)}` | |
| 47 | : bare; | |
| 48 | const [head = "", tail] = text.split("::"); | |
| 49 | const left = head ? head.split(":") : []; | |
| 50 | const right = tail ? tail.split(":") : []; | |
| 51 | const fill = tail === undefined ? 0 : 8 - left.length - right.length; | |
| 52 | return [...left, ...Array<string>(fill).fill("0"), ...right].map((group) => | |
| 53 | Number.parseInt(group, 16), | |
| 54 | ); | |
| 55 | }; | |
| 56 | ||
| 57 | /** | |
| 58 | * The IPv4 address a NAT64 or 6to4 address stands for, which a translator | |
| 59 | * will route to, so it is checked as well. Public NAT64 targets stay | |
| 60 | * reachable, as on IPv6-only networks. | |
| 61 | */ | |
| 62 | const embeddedIPv4 = (address: string) => { | |
| 63 | const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1; | |
| 64 | if (at === -1) return undefined; | |
| 65 | const groups = ipv6Groups(address); | |
| 66 | const high = groups[at] ?? 0; | |
| 67 | const low = groups[at + 1] ?? 0; | |
| 68 | return [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."); | |
| 69 | }; | |
| 70 | ||
| 71 | /** The addresses this machine's interfaces hold right now. */ | |
| 72 | const ownAddresses = () => { | |
| 73 | const own = new NodeNet.BlockList(); | |
| 74 | for (const entry of Object.values(NodeOS.networkInterfaces()).flat()) { | |
| 75 | if (entry) own.addAddress(entry.address, entry.family === "IPv6" ? "ipv6" : "ipv4"); | |
| 76 | } | |
| 77 | return own; | |
| 78 | }; | |
| 79 | ||
| 80 | /** | |
| 81 | * Whether `address` belongs to a local network or to this machine itself, | |
| 82 | * including a public address one of its interfaces holds. Both lists match | |
| 83 | * IPv4-mapped IPv6 against their IPv4 entries. | |
| 84 | */ | |
| 85 | const isLocal = (address: string, family: number): boolean => { | |
| 86 | const type = family === 6 ? "ipv6" : "ipv4"; | |
| 87 | if (LOCAL_ADDRESSES.check(address, type) || ownAddresses().check(address, type)) return true; | |
| 88 | const embedded = family === 6 ? embeddedIPv4(address) : undefined; | |
| 89 | return embedded !== undefined && isLocal(embedded, 4); | |
| 90 | }; | |
| 91 | ||
| 92 | /** | |
| 93 | * The addresses to connect to for `host`, or undefined when any address it | |
| 94 | * resolves to is local. The caller connects only to addresses checked here, | |
| 95 | * so a name that later resolves elsewhere (DNS rebinding) changes nothing. | |
| 96 | */ | |
| 97 | const publicAddresses = async (host: string) => { | |
| 98 | const addresses = NodeNet.isIP(host) | |
| 99 | ? [{ address: host, family: NodeNet.isIP(host) }] | |
| 100 | : await NodeDnsPromises.lookup(host, { all: true, verbatim: true }).catch(() => []); | |
| 101 | if (addresses.length === 0) return undefined; | |
| 102 | const local = addresses.some(({ address, family }) => isLocal(address, family)); | |
| 103 | return local ? undefined : addresses; | |
| 104 | }; | |
| 105 | ||
| 106 | // SOCKS5 (RFC 1928) replies: success, refused by rule, host unreachable, command unsupported. | |
| 107 | const reply = (code: number) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]); | |
| 108 | ||
| 109 | // What a client may send before its tunnel opens; a TLS hello fits easily. | |
| 110 | const MAX_EARLY_BYTES = 64 * 1024; | |
| 111 | ||
| 112 | /** | |
| 113 | * Answers and closes. The client is never read from again, so anything it | |
| 114 | * sent after its request is dropped rather than left buffered. | |
| 115 | */ | |
| 116 | const refuse = (client: NodeNet.Socket, code: number) => { | |
| 117 | client.end(reply(code), () => client.destroy()); | |
| 118 | }; | |
| 119 | ||
| 120 | /** The CONNECT target in a complete SOCKS5 request, or "short" when more bytes are needed. */ | |
| 121 | const readRequest = (data: Buffer) => { | |
| 122 | if (data.length < 5) return "short" as const; | |
| 123 | // Version 5, reserved byte 0. | |
| 124 | if (data[0] !== 5 || data[2] !== 0) return undefined; | |
| 125 | const type = data[3]; | |
| 126 | const end = type === 1 ? 10 : type === 3 ? 7 + data[4]! : type === 4 ? 22 : -1; | |
| 127 | if (end === -1) return undefined; | |
| 128 | if (data.length < end) return "short" as const; | |
| 129 | const host = | |
| 130 | type === 1 | |
| 131 | ? [...data.subarray(4, 8)].join(".") | |
| 132 | : type === 3 | |
| 133 | ? data.subarray(5, 5 + data[4]!).toString("latin1") | |
| 134 | : Array.from({ length: 8 }, (_, index) => | |
| 135 | data.readUInt16BE(4 + index * 2).toString(16), | |
| 136 | ).join(":"); | |
| 137 | return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) }; | |
| 138 | }; | |
| 139 | ||
| 140 | /** | |
| 141 | * Starts a SOCKS5 proxy on loopback for the life of the scope and returns its | |
| 142 | * port. The preview browser sends every connection through it, and it only | |
| 143 | * connects to public addresses. Chrome's Local Network Access misses some | |
| 144 | * requests, such as speculation-rules prefetches; nothing misses this. It | |
| 145 | * carries bytes only, so HTTP, TLS, and WebSockets pass through unchanged. | |
| 146 | */ | |
| 147 | export const publicProxy = Effect.acquireRelease( | |
| 148 | Effect.callback< | |
| 149 | { readonly server: NodeNet.Server; readonly sockets: Set<NodeNet.Socket> }, | |
| 150 | Error | |
| 151 | >((resume) => { | |
| 152 | const sockets = new Set<NodeNet.Socket>(); | |
| 153 | const track = (socket: NodeNet.Socket) => { | |
| 154 | sockets.add(socket); | |
| 155 | socket.on("close", () => sockets.delete(socket)); | |
| 156 | socket.on("error", () => socket.destroy()); | |
| 157 | return socket; | |
| 158 | }; | |
| 159 | const server = NodeNet.createServer((client) => { | |
| 160 | track(client); | |
| 161 | let data = Buffer.alloc(0); | |
| 162 | let greeted = false; | |
| 163 | const onData = (chunk: Buffer) => { | |
| 164 | data = Buffer.concat([data, chunk]); | |
| 165 | if (!greeted) { | |
| 166 | if (data.length < 2 || data.length < 2 + data[1]!) return; | |
| 167 | // Version 5, offering "no authentication". | |
| 168 | if (data[0] !== 5 || !data.subarray(2, 2 + data[1]!).includes(0)) { | |
| 169 | return void client.end(Buffer.from([5, 0xff])); | |
| 170 | } | |
| 171 | data = data.subarray(2 + data[1]!); | |
| 172 | greeted = true; | |
| 173 | client.write(Buffer.from([5, 0])); | |
| 174 | } | |
| 175 | const request = readRequest(data); | |
| 176 | if (request === "short") return; | |
| 177 | client.off("data", onData); | |
| 178 | if (request === undefined || request.command !== 1 || request.port === 0) { | |
| 179 | return refuse(client, 7); | |
| 180 | } | |
| 181 | // Keeps reading while the target resolves, so a client that leaves is | |
| 182 | // noticed, and holds what it sends early up to a small cap. | |
| 183 | const early: Array<Buffer> = [request.rest]; | |
| 184 | let earlyBytes = request.rest.length; | |
| 185 | const holdEarly = (chunk: Buffer) => { | |
| 186 | earlyBytes += chunk.length; | |
| 187 | if (earlyBytes > MAX_EARLY_BYTES) return void client.destroy(); | |
| 188 | early.push(chunk); | |
| 189 | }; | |
| 190 | client.on("data", holdEarly); | |
| 191 | void publicAddresses(request.host).then((addresses) => { | |
| 192 | if (client.destroyed) return; | |
| 193 | client.off("data", holdEarly); | |
| 194 | if (addresses === undefined) return refuse(client, 2); | |
| 195 | client.pause(); | |
| 196 | // Tries every checked address, IPv6 and IPv4 alike, so a host is | |
| 197 | // still reached on a network whose IPv6 route is broken. A name goes | |
| 198 | // through `lookup`, which hands back only the checked addresses; an | |
| 199 | // address literal connects as itself. | |
| 200 | const upstream = track( | |
| 201 | NodeNet.connect({ | |
| 202 | host: request.host, | |
| 203 | port: request.port, | |
| 204 | autoSelectFamily: true, | |
| 205 | lookup: (_host, options, callback) => | |
| 206 | options.all | |
| 207 | ? callback(null, addresses) | |
| 208 | : callback(null, addresses[0]!.address, addresses[0]!.family), | |
| 209 | }), | |
| 210 | ); | |
| 211 | upstream.once("connect", () => { | |
| 212 | client.write(reply(0)); | |
| 213 | for (const chunk of early) upstream.write(chunk); | |
| 214 | upstream.pipe(client); | |
| 215 | client.pipe(upstream); | |
| 216 | }); | |
| 217 | upstream.on("close", () => client.destroy()); | |
| 218 | client.on("close", () => upstream.destroy()); | |
| 219 | }); | |
| 220 | }; | |
| 221 | client.on("data", onData); | |
| 222 | }); | |
| 223 | // A failure to listen fails this preview; once listening, a server error | |
| 224 | // must not reach Node as an unhandled event either. | |
| 225 | let listening = false; | |
| 226 | server.on("error", (error) => { | |
| 227 | if (!listening) resume(Effect.fail(error)); | |
| 228 | }); | |
| 229 | server.listen(0, "127.0.0.1", () => { | |
| 230 | listening = true; | |
| 231 | resume(Effect.succeed({ server, sockets })); | |
| 232 | }); | |
| 233 | }), | |
| 234 | ({ server, sockets }) => | |
| 235 | Effect.callback<void>((resume) => { | |
| 236 | for (const socket of sockets) socket.destroy(); | |
| 237 | server.close(() => resume(Effect.void)); | |
| 238 | }), | |
| 239 | ).pipe( | |
| 240 | Effect.map(({ server }) => { | |
| 241 | const address = server.address(); | |
| 242 | return typeof address === "object" && address !== null ? address.port : 0; | |
| 243 | }), | |
| 244 | ); |
apps/server/src/http.test.ts+2-2| ... | ... | @@ -675,14 +675,14 @@ describe("assetResponseHeaders", () => { |
| 675 | 675 | assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }), |
| 676 | 676 | ).toMatchObject({ |
| 677 | 677 | "Content-Type": "text/html; charset=utf-8", |
| 678 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals", | |
| 678 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", | |
| 679 | 679 | }); |
| 680 | 680 | }); |
| 681 | 681 | it("serves HTML assets as utf-8 inside a sandboxed origin", () => { |
| 682 | 682 | for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { |
| 683 | 683 | expect(assetResponseHeaders(path)).toMatchObject({ |
| 684 | 684 | "Content-Type": "text/html; charset=utf-8", |
| 685 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals", | |
| 685 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", | |
| 686 | 686 | }); |
| 687 | 687 | } |
| 688 | 688 | }); |
apps/server/src/http.ts+3-1| ... | ... | @@ -56,7 +56,9 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin |
| 56 | 56 | // HTML previews are agent output, not the app. The sandbox gives the document an |
| 57 | 57 | // opaque origin: scripts run, but same-origin cookies, storage, and API calls are |
| 58 | 58 | // out of reach. Relative sibling assets still load through their signed URLs. |
| 59 | const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; | |
| 59 | // No modals: agent HTML can open without a click (inline renders, and mobile | |
| 60 | // loads it as the top document), and must not raise blocking dialogs. | |
| 61 | const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups"; | |
| 60 | 62 | |
| 61 | 63 | // Types a browser may render as a document if a proxy strips the disposition |
| 62 | 64 | // header. Downloads of these fall back to octet-stream. |
apps/server/src/mcp/McpHttpServer.ts+54-8| ... | ... | @@ -11,13 +11,15 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Sink from "effect/Sink"; |
| 12 | 12 | import * as Stream from "effect/Stream"; |
| 13 | 13 | import type * as Types from "effect/Types"; |
| 14 | import { McpProtocol, McpSchema, McpServer, Tool } from "effect/ai"; | |
| 14 | import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai"; | |
| 15 | 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 16 | import { PreviewAutomationError } from "@t3tools/contracts"; | |
| 16 | import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; | |
| 17 | 17 | |
| 18 | 18 | import packageJson from "../../package.json" with { type: "json" }; |
| 19 | 19 | import * as ServerConfig from "../config.ts"; |
| 20 | 20 | import * as DeviceService from "../device/DeviceService.ts"; |
| 21 | import * as HtmlRender from "../htmlRender/HtmlRender.ts"; | |
| 22 | import * as PreviewBrowser from "../htmlRender/PreviewBrowser.ts"; | |
| 21 | 23 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 22 | 24 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 23 | 25 | import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; |
| ... | ... | @@ -58,6 +60,11 @@ import { |
| 58 | 60 | DeviceScreenshotToolkit, |
| 59 | 61 | DeviceStandardToolkit, |
| 60 | 62 | } from "./toolkits/device/tools.ts"; |
| 63 | import { | |
| 64 | HtmlPreviewToolkitHandlersLive, | |
| 65 | HtmlRenderToolkitHandlersLive, | |
| 66 | } from "./toolkits/html/handlers.ts"; | |
| 67 | import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; | |
| 61 | 68 | |
| 62 | 69 | const unauthorized = HttpServerResponse.jsonUnsafe( |
| 63 | 70 | { |
| ... | ... | @@ -510,12 +517,13 @@ interface ImageToolResult { |
| 510 | 517 | } |
| 511 | 518 | |
| 512 | 519 | /** |
| 513 | * Failures surface only their tag: the remote message may carry renderer or | |
| 514 | * device output the agent should not see, and the tag is what it can act on. | |
| 520 | * Failures surface only their tag unless the tool describes them: the remote | |
| 521 | * message may carry renderer or device output the agent should not see, and | |
| 522 | * the tag is what it can act on. A describer returns a server-built message. | |
| 515 | 523 | */ |
| 516 | 524 | const imageToolFailure = |
| 517 | (toolName: string, operation: string, failureText: string) => | |
| 518 | <E>(cause: Cause.Cause<E>) => { | |
| 525 | <E>(toolName: string, operation: string, failureText: string | ((error: E) => string)) => | |
| 526 | (cause: Cause.Cause<E>) => { | |
| 519 | 527 | if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { |
| 520 | 528 | return Effect.failCause(cause).pipe(Effect.orDie); |
| 521 | 529 | } |
| ... | ... | @@ -528,6 +536,10 @@ const imageToolFailure = |
| 528 | 536 | typeof firstFailure._tag === "string" |
| 529 | 537 | ? firstFailure._tag |
| 530 | 538 | : `${toolName}Error`; |
| 539 | const message = | |
| 540 | typeof failureText === "string" || failures[0] === undefined | |
| 541 | ? undefined | |
| 542 | : failureText(failures[0].error); | |
| 531 | 543 | const result = new McpSchema.CallToolResult({ |
| 532 | 544 | isError: true, |
| 533 | 545 | structuredContent: { |
| ... | ... | @@ -535,9 +547,16 @@ const imageToolFailure = |
| 535 | 547 | _tag: errorTag, |
| 536 | 548 | operation, |
| 537 | 549 | failureCount: failures.length, |
| 550 | ...(message === undefined ? {} : { message }), | |
| 538 | 551 | }, |
| 539 | 552 | }, |
| 540 | content: [{ type: "text", text: failureText }], | |
| 553 | // Some clients show only the text content and others only structuredContent, so both carry it. | |
| 554 | content: [ | |
| 555 | { | |
| 556 | type: "text", | |
| 557 | text: typeof failureText === "string" ? failureText : (message ?? `${toolName} failed.`), | |
| 558 | }, | |
| 559 | ], | |
| 541 | 560 | }); |
| 542 | 561 | return Effect.logWarning(`${toolName} failed`, { |
| 543 | 562 | operation, |
| ... | ... | @@ -563,7 +582,7 @@ const registerImageTool = <T extends Tool.Any, E, R>( |
| 563 | 582 | McpInvocationContext.McpInvocationContext |
| 564 | 583 | >, |
| 565 | 584 | operation: string, |
| 566 | failureText: string, | |
| 585 | failureText: string | ((error: E) => string), | |
| 567 | 586 | ) => |
| 568 | 587 | Effect.gen(function* () { |
| 569 | 588 | const server = yield* McpServer.McpServer; |
| ... | ... | @@ -647,6 +666,32 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh |
| 647 | 666 | ); |
| 648 | 667 | }); |
| 649 | 668 | |
| 669 | const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); | |
| 670 | ||
| 671 | const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { | |
| 672 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 673 | const built = yield* HtmlPreviewToolkit; | |
| 674 | yield* registerImageTool( | |
| 675 | HtmlPreviewTool, | |
| 676 | (payload) => | |
| 677 | built | |
| 678 | .handle("html_preview", payload) | |
| 679 | .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), | |
| 680 | (effect) => effect.pipe(Effect.provideService(HtmlRender.HtmlRender, htmlRender)), | |
| 681 | "preview", | |
| 682 | // Parameter errors and HTML render errors are both written by the server for the agent. | |
| 683 | (error) => | |
| 684 | isOrchestratorMcpFailure(error) || AiError.isAiError(error) | |
| 685 | ? error.message | |
| 686 | : "HTML preview failed.", | |
| 687 | ); | |
| 688 | }); | |
| 689 | ||
| 690 | export const HtmlToolkitRegistrationLive = Layer.mergeAll( | |
| 691 | McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlRenderToolkitHandlersLive)), | |
| 692 | Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlPreviewToolkitHandlersLive)), | |
| 693 | ).pipe(Layer.provide(HtmlRender.layer), Layer.provide(PreviewBrowser.layer)); | |
| 694 | ||
| 650 | 695 | const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( |
| 651 | 696 | Layer.provide(PreviewStandardToolkitHandlersLive), |
| 652 | 697 | ); |
| ... | ... | @@ -726,4 +771,5 @@ export const layer = Layer.mergeAll( |
| 726 | 771 | WorktreeToolkitRegistrationLive, |
| 727 | 772 | PullRequestsToolkitRegistrationLive, |
| 728 | 773 | DeviceToolkitRegistrationLive, |
| 774 | HtmlToolkitRegistrationLive, | |
| 729 | 775 | ).pipe(Layer.provideMerge(McpTransportLive)); |
apps/server/src/mcp/toolkits/core.test.ts+54| ... | ... | @@ -1,18 +1,23 @@ |
| 1 | 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import { |
| 4 | 5 | DEFAULT_SERVER_SETTINGS, |
| 5 | 6 | ChatImageAttachment, |
| 6 | 7 | EnvironmentId, |
| 7 | 8 | ProviderInstanceId, |
| 9 | RunId, | |
| 8 | 10 | ThreadId, |
| 11 | type OrchestrationV2ThreadShell, | |
| 9 | 12 | } from "@t3tools/contracts"; |
| 10 | 13 | import * as Effect from "effect/Effect"; |
| 11 | 14 | import * as Layer from "effect/Layer"; |
| 12 | 15 | import * as Schema from "effect/Schema"; |
| 13 | 16 | import { McpAttachmentInput } from "./attachment/input.ts"; |
| 14 | 17 | import { McpSchema, McpServer, Tool } from "effect/ai"; |
| 18 | import { FetchHttpClient } from "effect/http"; | |
| 15 | 19 | |
| 20 | import * as ServerConfig from "../../config.ts"; | |
| 16 | 21 | import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; |
| 17 | 22 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 18 | 23 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| ... | ... | @@ -40,11 +45,13 @@ const declaredFailure = (result: McpSchema.CallToolResult) => { |
| 40 | 45 | return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; |
| 41 | 46 | }; |
| 42 | 47 | import { PullRequestsToolkit } from "./pullRequests/tools.ts"; |
| 48 | import { HtmlToolkit } from "./html/tools.ts"; | |
| 43 | 49 | import { |
| 44 | 50 | resolveT3McpToolDefinition, |
| 45 | 51 | resolveT3McpToolPresentation, |
| 46 | 52 | resolveT3McpToolSummaryAction, |
| 47 | 53 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 54 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | |
| 48 | 55 | |
| 49 | 56 | const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); |
| 50 | 57 | |
| ... | ... | @@ -61,6 +68,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => { |
| 61 | 68 | PreviewControlsToolkit, |
| 62 | 69 | DeviceToolkit, |
| 63 | 70 | PullRequestsToolkit, |
| 71 | HtmlToolkit, | |
| 64 | 72 | ]) { |
| 65 | 73 | for (const tool of Object.values(toolkit.tools)) { |
| 66 | 74 | expect(names.has(tool.name)).toBe(false); |
| ... | ... | @@ -172,6 +180,52 @@ it.effect("returns a bounded public failure without serializing storage causes", |
| 172 | 180 | ), |
| 173 | 181 | ); |
| 174 | 182 | |
| 183 | it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () => | |
| 184 | Effect.gen(function* () { | |
| 185 | const server = yield* McpServer.McpServer; | |
| 186 | const result = yield* server | |
| 187 | .callTool({ | |
| 188 | name: "html_render", | |
| 189 | arguments: { html: "<p>Revenue</p>", title: "Revenue", height: 240 }, | |
| 190 | }) | |
| 191 | .pipe( | |
| 192 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), | |
| 193 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 194 | ); | |
| 195 | const reference = htmlRenderFromToolItem({ | |
| 196 | toolName: "t3-code.html_render", | |
| 197 | output: result.structuredContent, | |
| 198 | }); | |
| 199 | expect(reference).toMatchObject({ title: "Revenue", height: 240 }); | |
| 200 | expect( | |
| 201 | htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_render", output: result.content }), | |
| 202 | ).toEqual(reference); | |
| 203 | }).pipe( | |
| 204 | Effect.provide( | |
| 205 | McpHttpServer.HtmlToolkitRegistrationLive.pipe( | |
| 206 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 207 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })), | |
| 208 | Layer.provide(NodeServices.layer), | |
| 209 | // The preview browser is not installed in a fresh home, so nothing downloads. | |
| 210 | Layer.provide(FetchHttpClient.layer), | |
| 211 | Layer.provide( | |
| 212 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 213 | // A live run: publishing stores a page, so it needs the caller's active turn. | |
| 214 | getThreadShell: () => | |
| 215 | Effect.succeed({ | |
| 216 | id: threadId, | |
| 217 | deletedAt: null, | |
| 218 | archivedAt: null, | |
| 219 | activeRunId: RunId.make("mcp-core-run"), | |
| 220 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 221 | } as OrchestrationV2ThreadShell), | |
| 222 | }), | |
| 223 | ), | |
| 224 | ), | |
| 225 | ), | |
| 226 | ), | |
| 227 | ); | |
| 228 | ||
| 175 | 229 | it("keeps MCP preference output allowlisted and Unicode-bounded", () => { |
| 176 | 230 | const settings = { |
| 177 | 231 | ...DEFAULT_SERVER_SETTINGS, |
apps/server/src/mcp/toolkits/html/handlers.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { OrchestratorMcpFailure } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; | |
| 5 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 6 | import { readMutationCaller } from "../../threadAccess.ts"; | |
| 7 | import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts"; | |
| 8 | ||
| 9 | const INVALID_PAGE_ERRORS = new Set([ | |
| 10 | "HtmlRenderImagesNotFoundError", | |
| 11 | "HtmlRenderImageTooLargeError", | |
| 12 | "HtmlRenderPageTooLargeError", | |
| 13 | ]); | |
| 14 | ||
| 15 | // Every HTML render error message is built on the server and tells the agent what to do next. | |
| 16 | const toFailure = (error: { readonly _tag: string; readonly message: string }) => | |
| 17 | new OrchestratorMcpFailure({ | |
| 18 | code: INVALID_PAGE_ERRORS.has(error._tag) ? "invalid_request" : "orchestration_error", | |
| 19 | message: error.message, | |
| 20 | }); | |
| 21 | ||
| 22 | const handlers = { | |
| 23 | html_preview: (input) => | |
| 24 | Effect.gen(function* () { | |
| 25 | // The headless browser runs on the host and can open local files, so | |
| 26 | // only agents T3 launched, which already work on this machine, get it. | |
| 27 | yield* McpInvocationContext.requireThreadScope( | |
| 28 | yield* McpInvocationContext.McpInvocationContext, | |
| 29 | "html_preview", | |
| 30 | ); | |
| 31 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 32 | const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure)); | |
| 33 | return { | |
| 34 | ...preview, | |
| 35 | screenshot: { | |
| 36 | mimeType: "image/png" as const, | |
| 37 | data: png, | |
| 38 | width: preview.width, | |
| 39 | height: preview.capturedHeight, | |
| 40 | }, | |
| 41 | }; | |
| 42 | }), | |
| 43 | html_render: (input) => | |
| 44 | Effect.gen(function* () { | |
| 45 | // The page is stored in the calling thread, so it needs that thread's | |
| 46 | // live run, like any other write. | |
| 47 | const { scope } = yield* readMutationCaller(); | |
| 48 | const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render"); | |
| 49 | const htmlRender = yield* HtmlRender.HtmlRender; | |
| 50 | const reference = yield* htmlRender | |
| 51 | .publish({ threadId: thread.threadId, ...input }) | |
| 52 | .pipe(Effect.mapError(toFailure)); | |
| 53 | return { | |
| 54 | htmlRender: reference, | |
| 55 | message: | |
| 56 | "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.", | |
| 57 | }; | |
| 58 | }), | |
| 59 | } satisfies Parameters<typeof HtmlToolkit.toLayer>[0]; | |
| 60 | ||
| 61 | export const HtmlPreviewToolkitHandlersLive = HtmlPreviewToolkit.toLayer({ | |
| 62 | html_preview: handlers.html_preview, | |
| 63 | }); | |
| 64 | ||
| 65 | export const HtmlRenderToolkitHandlersLive = HtmlRenderToolkit.toLayer({ | |
| 66 | html_render: handlers.html_render, | |
| 67 | }); |
apps/server/src/mcp/toolkits/html/tools.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import { OrchestratorMcpFailure } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | HTML_RENDER_COLUMN_WIDTH, | |
| 4 | HTML_RENDER_LAYOUT_GUIDE, | |
| 5 | HTML_RENDER_MAX_HEIGHT, | |
| 6 | HTML_RENDER_MAX_TITLE_LENGTH, | |
| 7 | HTML_RENDER_MIN_HEIGHT, | |
| 8 | HTML_RENDER_THEME_GUIDE, | |
| 9 | HTML_RENDER_TOOL_NAME, | |
| 10 | } from "@t3tools/shared/htmlRender"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import { Tool, Toolkit } from "effect/ai"; | |
| 13 | ||
| 14 | import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; | |
| 15 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 16 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 17 | ||
| 18 | const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_000)).annotate({ | |
| 19 | description: "A complete, self-contained HTML document.", | |
| 20 | }); | |
| 21 | ||
| 22 | const PAGE_RULES = | |
| 23 | 'Write one self-contained document with inline <style> and <script>. Local images written as absolute file paths (src="/abs/shot.png", CSS url(/abs/bg.webp), or a JS string) are inlined automatically; remote http(s) URLs, such as a CDN chart library, load as-is.'; | |
| 24 | ||
| 25 | export const HtmlPreviewTool = Tool.make("html_preview", { | |
| 26 | description: `Render an HTML page in T3's headless browser and get back a PNG screenshot, contentHeight (the height the page needs at this width), and its console output: log, info, warning, error, and uncaught exceptions, with stack traces pointing into page.html. console.log is a fine way to report your own checks. Use it to check and iterate on a page before html_render. The first preview on a machine can report that T3 is still installing its preview browser; call again a minute later. ${PAGE_RULES} The page gets the theme variables and layout described in html_render.`, | |
| 27 | parameters: Schema.Struct({ | |
| 28 | html: Html, | |
| 29 | width: Schema.optional( | |
| 30 | Schema.Int.annotate({ | |
| 31 | description: `Viewport width in CSS pixels, 240-1600. Defaults to ${HTML_RENDER_COLUMN_WIDTH}, the reply column; use about 390 to check phones.`, | |
| 32 | }), | |
| 33 | ), | |
| 34 | appearance: Schema.optional( | |
| 35 | Schema.Literals(["dark", "light"]).annotate({ | |
| 36 | description: "Theme to preview. Defaults to dark.", | |
| 37 | }), | |
| 38 | ), | |
| 39 | }), | |
| 40 | success: Schema.Struct({ | |
| 41 | width: Schema.Int, | |
| 42 | contentHeight: Schema.Int, | |
| 43 | capturedHeight: Schema.Int, | |
| 44 | consoleMessages: Schema.Array( | |
| 45 | Schema.Struct({ | |
| 46 | level: Schema.Literals(["log", "info", "warning", "error"]), | |
| 47 | text: Schema.String, | |
| 48 | }), | |
| 49 | ), | |
| 50 | missingImages: Schema.optional(Schema.Array(Schema.String)), | |
| 51 | screenshot: Schema.Struct({ | |
| 52 | mimeType: Schema.Literal("image/png"), | |
| 53 | data: Schema.String, | |
| 54 | width: Schema.Int, | |
| 55 | height: Schema.Int, | |
| 56 | }), | |
| 57 | }), | |
| 58 | failure: OrchestratorMcpFailure, | |
| 59 | dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender], | |
| 60 | }) | |
| 61 | .annotate(Tool.Title, "Preview HTML") | |
| 62 | .annotate(Tool.Readonly, true) | |
| 63 | .annotate(Tool.Destructive, false) | |
| 64 | .annotate(Tool.Idempotent, true) | |
| 65 | .annotate(Tool.OpenWorld, true); | |
| 66 | ||
| 67 | // Read-only in the MCP sense: it shows a page in the caller's own thread and | |
| 68 | // touches no workspace, so plan mode and read-only sandboxes can use it. | |
| 69 | // Open-world, since the page may load remote resources, as in a preview. | |
| 70 | const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { | |
| 71 | description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width, up to height; anything taller scrolls inside the frame. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, | |
| 72 | parameters: Schema.Struct({ | |
| 73 | html: Html, | |
| 74 | title: Schema.String.check( | |
| 75 | Schema.isMinLength(1), | |
| 76 | Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH), | |
| 77 | ).annotate({ description: "Short name for the page." }), | |
| 78 | height: Schema.Int.annotate({ | |
| 79 | description: `The most the frame may take, in CSS pixels, ${HTML_RENDER_MIN_HEIGHT}-${HTML_RENDER_MAX_HEIGHT}. Use html_preview's contentHeight, or less to make long content scroll inside the frame.`, | |
| 80 | }), | |
| 81 | }), | |
| 82 | success: Schema.Struct({ | |
| 83 | htmlRender: Schema.Struct({ | |
| 84 | attachmentId: Schema.String, | |
| 85 | title: Schema.String, | |
| 86 | height: Schema.Number, | |
| 87 | heights: Schema.optional(Schema.Array(Schema.Tuple([Schema.Int, Schema.Int]))), | |
| 88 | }), | |
| 89 | message: Schema.String, | |
| 90 | }), | |
| 91 | failure: OrchestratorMcpFailure, | |
| 92 | failureMode: "return", | |
| 93 | dependencies: [ | |
| 94 | McpInvocationContext.McpInvocationContext, | |
| 95 | ThreadManagementService.ThreadManagementService, | |
| 96 | HtmlRender.HtmlRender, | |
| 97 | ], | |
| 98 | }) | |
| 99 | .annotate(Tool.Title, "Render HTML") | |
| 100 | .annotate(Tool.Readonly, true) | |
| 101 | .annotate(Tool.Destructive, false) | |
| 102 | .annotate(Tool.Idempotent, false) | |
| 103 | .annotate(Tool.OpenWorld, true); | |
| 104 | ||
| 105 | export const HtmlPreviewToolkit = Toolkit.make(HtmlPreviewTool); | |
| 106 | ||
| 107 | export const HtmlRenderToolkit = Toolkit.make(HtmlRenderTool); | |
| 108 | ||
| 109 | export const HtmlToolkit = Toolkit.make(HtmlPreviewTool, HtmlRenderTool); |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+2| ... | ... | @@ -52,6 +52,7 @@ import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| 52 | 52 | import * as ServerConfig from "../../config.ts"; |
| 53 | 53 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 54 | 54 | import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; |
| 55 | import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; | |
| 55 | 56 | import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; |
| 56 | 57 | import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; |
| 57 | 58 | import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; |
| ... | ... | @@ -655,6 +656,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { |
| 655 | 656 | ...Object.values(ProjectToolkit.tools), |
| 656 | 657 | ...Object.values(EnvironmentToolkit.tools), |
| 657 | 658 | ...Object.values(PreviewControlsToolkit.tools), |
| 659 | ...Object.values(HtmlToolkit.tools), | |
| 658 | 660 | ] |
| 659 | 661 | .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) |
| 660 | 662 | .map((tool) => `mcp__t3-code__${tool.name}`) |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+2| ... | ... | @@ -946,6 +946,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [ |
| 946 | 946 | "mcp__t3-code__t3_environment_read", |
| 947 | 947 | "mcp__t3-code__t3_queue_list", |
| 948 | 948 | "mcp__t3-code__t3_queue_read", |
| 949 | "mcp__t3-code__html_preview", | |
| 950 | "mcp__t3-code__html_render", | |
| 949 | 951 | ]; |
| 950 | 952 | |
| 951 | 953 | // Claude Code aborts an HTTP MCP call after 60 s ("The operation timed out.") |
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts+4-1| ... | ... | @@ -108,8 +108,11 @@ function formatMcpContent(result: unknown): string { |
| 108 | 108 | if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); |
| 109 | 109 | } |
| 110 | 110 | } |
| 111 | // Most T3 tools mirror structuredContent in a text block. Repeating it would | |
| 112 | // leave T3's own output parsing two JSON documents instead of one. | |
| 111 | 113 | if (record.structuredContent !== undefined) { |
| 112 | texts.push(JSON.stringify(record.structuredContent)); | |
| 114 | const structured = JSON.stringify(record.structuredContent); | |
| 115 | if (!texts.includes(structured)) texts.push(structured); | |
| 113 | 116 | } |
| 114 | 117 | if (texts.length > 0) return texts.join("\\n"); |
| 115 | 118 | return JSON.stringify(result); |
apps/server/src/orchestration-v2/ProjectionStore.ts+40-11| ... | ... | @@ -70,6 +70,7 @@ import * as Schema from "effect/Schema"; |
| 70 | 70 | import * as SqlClient from "effect/sql/SqlClient"; |
| 71 | 71 | import type * as Statement from "effect/sql/Statement"; |
| 72 | 72 | |
| 73 | import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; | |
| 73 | 74 | import { |
| 74 | 75 | isThreadHistoryUserTurn, |
| 75 | 76 | isThreadHistoryTurnStart, |
| ... | ... | @@ -4556,13 +4557,37 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 4556 | 4557 | ); |
| 4557 | 4558 | |
| 4558 | 4559 | const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => |
| 4559 | sql<{ id: string }>` | |
| 4560 | Effect.all([ | |
| 4561 | sql<{ id: string }>` | |
| 4560 | 4562 | SELECT DISTINCT json_extract(attachment.value, '$.id') AS id |
| 4561 | 4563 | FROM orchestration_v2_projection_messages AS message, |
| 4562 | 4564 | json_each(message.payload_json, '$.attachments') AS attachment |
| 4563 | 4565 | WHERE message.thread_id = ${threadId} |
| 4564 | `.pipe( | |
| 4565 | Effect.map((rows) => rows.map((row) => row.id)), | |
| 4566 | `, | |
| 4567 | // Pages published by html_render live in the attachment store too. | |
| 4568 | sql<{ payload_json: string }>` | |
| 4569 | SELECT payload_json | |
| 4570 | FROM orchestration_v2_projection_turn_items | |
| 4571 | WHERE thread_id = ${threadId} | |
| 4572 | AND type = 'dynamic_tool' | |
| 4573 | AND payload_json LIKE '%htmlRender%' | |
| 4574 | `, | |
| 4575 | ]).pipe( | |
| 4576 | Effect.map(([messages, renders]) => [ | |
| 4577 | ...new Set([ | |
| 4578 | ...messages.map((row) => row.id), | |
| 4579 | ...threadHtmlRenderAttachmentIds( | |
| 4580 | threadId, | |
| 4581 | renders.map((row) => { | |
| 4582 | const item = parseEncodedPayload(row.payload_json); | |
| 4583 | return { | |
| 4584 | toolName: typeof item.toolName === "string" ? item.toolName : null, | |
| 4585 | output: item.output, | |
| 4586 | }; | |
| 4587 | }), | |
| 4588 | ), | |
| 4589 | ]), | |
| 4590 | ]), | |
| 4566 | 4591 | Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), |
| 4567 | 4592 | ); |
| 4568 | 4593 | |
| ... | ... | @@ -5871,17 +5896,21 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5871 | 5896 | ), |
| 5872 | 5897 | ), |
| 5873 | 5898 | getThreadAttachmentIds: (threadId) => |
| 5874 | service | |
| 5875 | .getThreadProjection(threadId) | |
| 5876 | .pipe( | |
| 5877 | Effect.map((projection) => [ | |
| 5878 | ...new Set( | |
| 5879 | projection.messages.flatMap((message) => | |
| 5880 | message.attachments.map((attachment) => attachment.id), | |
| 5899 | service.getThreadProjection(threadId).pipe( | |
| 5900 | Effect.map((projection) => [ | |
| 5901 | ...new Set([ | |
| 5902 | ...projection.messages.flatMap((message) => | |
| 5903 | message.attachments.map((attachment) => attachment.id), | |
| 5904 | ), | |
| 5905 | ...threadHtmlRenderAttachmentIds( | |
| 5906 | threadId, | |
| 5907 | projection.turnItems.flatMap((item) => | |
| 5908 | item.type === "dynamic_tool" ? [item] : [], | |
| 5881 | 5909 | ), |
| 5882 | 5910 | ), |
| 5883 | 5911 | ]), |
| 5884 | ), | |
| 5912 | ]), | |
| 5913 | ), | |
| 5885 | 5914 | getThreadRecords: (threadId, fields, filter) => |
| 5886 | 5915 | Effect.gen(function* () { |
| 5887 | 5916 | const projection = (yield* Ref.get(replayState)).projections.get(threadId); |
apps/server/src/provider/AntigravityInstallation.ts+4-118| ... | ... | @@ -27,10 +27,9 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 27 | 27 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 28 | 28 | import * as NodeCrypto from "node:crypto"; |
| 29 | 29 | import * as NodeFSP from "node:fs/promises"; |
| 30 | import type * as NodeStream from "node:stream"; | |
| 31 | import * as Yauzl from "yauzl"; | |
| 32 | 30 | |
| 33 | 31 | import * as ServerConfig from "../config.ts"; |
| 32 | import { openZipArchive } from "../zipArchive.ts"; | |
| 34 | 33 | import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; |
| 35 | 34 | import { |
| 36 | 35 | buildAntigravityAcpSpawnInput, |
| ... | ... | @@ -148,121 +147,6 @@ function isRunning(state: ProviderInstallState) { |
| 148 | 147 | ); |
| 149 | 148 | } |
| 150 | 149 | |
| 151 | /** Open only a verified local archive. Entries stay lazy and extraction stays bounded. */ | |
| 152 | const openArchive = Effect.fn("AntigravityInstallation.openArchive")(function* ( | |
| 153 | archivePath: string, | |
| 154 | ) { | |
| 155 | const opened = yield* Effect.acquireRelease( | |
| 156 | Effect.callback< | |
| 157 | { | |
| 158 | readonly zip: Yauzl.ZipFile; | |
| 159 | readonly error: () => AntigravityInstallationError | undefined; | |
| 160 | readonly close: Effect.Effect<void>; | |
| 161 | }, | |
| 162 | AntigravityInstallationError | |
| 163 | >((resume) => { | |
| 164 | Yauzl.open( | |
| 165 | archivePath, | |
| 166 | { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, | |
| 167 | (error, zip) => { | |
| 168 | if (error || !zip) { | |
| 169 | resume( | |
| 170 | Effect.fail( | |
| 171 | installationError("extract", "Could not open the verified archive.", error), | |
| 172 | ), | |
| 173 | ); | |
| 174 | return; | |
| 175 | } | |
| 176 | let closed = false; | |
| 177 | let archiveError: AntigravityInstallationError | undefined; | |
| 178 | zip.on("close", () => { | |
| 179 | closed = true; | |
| 180 | }); | |
| 181 | zip.on("error", (cause: unknown) => { | |
| 182 | archiveError = installationError("extract", "The archive could not be read.", cause); | |
| 183 | }); | |
| 184 | resume( | |
| 185 | Effect.succeed({ | |
| 186 | zip, | |
| 187 | error: () => archiveError, | |
| 188 | close: Effect.callback<void>((finish) => { | |
| 189 | if (closed) { | |
| 190 | finish(Effect.void); | |
| 191 | return; | |
| 192 | } | |
| 193 | const onClose = () => { | |
| 194 | zip.removeListener("error", onError); | |
| 195 | finish(Effect.void); | |
| 196 | }; | |
| 197 | const onError = (cause: unknown) => { | |
| 198 | zip.removeListener("close", onClose); | |
| 199 | finish( | |
| 200 | Effect.die(installationError("extract", "Could not close the archive.", cause)), | |
| 201 | ); | |
| 202 | }; | |
| 203 | zip.once("close", onClose); | |
| 204 | zip.once("error", onError); | |
| 205 | zip.close(); | |
| 206 | }), | |
| 207 | }), | |
| 208 | ); | |
| 209 | }, | |
| 210 | ); | |
| 211 | }), | |
| 212 | (opened) => opened.close, | |
| 213 | ); | |
| 214 | ||
| 215 | const next = Effect.callback<Yauzl.Entry | null, AntigravityInstallationError>((resume) => { | |
| 216 | const existingError = opened.error(); | |
| 217 | if (existingError) { | |
| 218 | resume(Effect.fail(existingError)); | |
| 219 | return; | |
| 220 | } | |
| 221 | const cleanup = () => { | |
| 222 | opened.zip.removeListener("entry", onEntry); | |
| 223 | opened.zip.removeListener("end", onEnd); | |
| 224 | opened.zip.removeListener("error", onError); | |
| 225 | }; | |
| 226 | const onEntry = (entry: Yauzl.Entry) => { | |
| 227 | cleanup(); | |
| 228 | resume(Effect.succeed(entry)); | |
| 229 | }; | |
| 230 | const onEnd = () => { | |
| 231 | cleanup(); | |
| 232 | resume(Effect.succeed(null)); | |
| 233 | }; | |
| 234 | const onError = (cause: unknown) => { | |
| 235 | cleanup(); | |
| 236 | resume(Effect.fail(installationError("extract", "The archive could not be read.", cause))); | |
| 237 | }; | |
| 238 | opened.zip.once("entry", onEntry); | |
| 239 | opened.zip.once("end", onEnd); | |
| 240 | opened.zip.once("error", onError); | |
| 241 | opened.zip.readEntry(); | |
| 242 | return Effect.sync(cleanup); | |
| 243 | }); | |
| 244 | ||
| 245 | const streamEntry = (entry: Yauzl.Entry) => | |
| 246 | Effect.acquireRelease( | |
| 247 | Effect.callback<NodeStream.Readable, AntigravityInstallationError>((resume) => { | |
| 248 | opened.zip.openReadStream(entry, (cause, readable) => { | |
| 249 | resume( | |
| 250 | cause || !readable | |
| 251 | ? Effect.fail( | |
| 252 | installationError("extract", "Could not read an archive member.", cause), | |
| 253 | ) | |
| 254 | : Effect.succeed(readable), | |
| 255 | ); | |
| 256 | }); | |
| 257 | }), | |
| 258 | (readable) => | |
| 259 | Effect.sync(() => { | |
| 260 | readable.destroy(); | |
| 261 | }), | |
| 262 | ); | |
| 263 | return { entryCount: opened.zip.entryCount, next, streamEntry }; | |
| 264 | }); | |
| 265 | ||
| 266 | 150 | export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* ( |
| 267 | 151 | options: AntigravityInstallationOptions, |
| 268 | 152 | ) { |
| ... | ... | @@ -665,7 +549,9 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma |
| 665 | 549 | |
| 666 | 550 | yield* report("extracting", "Extracting the verified runtime."); |
| 667 | 551 | yield* Effect.gen(function* () { |
| 668 | const archive = yield* openArchive(archivePath); | |
| 552 | const archive = yield* openZipArchive(archivePath, (detail, cause) => | |
| 553 | installationError("extract", detail, cause), | |
| 554 | ); | |
| 669 | 555 | if (archive.entryCount !== 2) { |
| 670 | 556 | return yield* installationError( |
| 671 | 557 | "extract", |
apps/server/src/provider/T3OrchestrationInstructions.ts+4| ... | ... | @@ -28,6 +28,10 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO |
| 28 | 28 | Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them. |
| 29 | 29 | |
| 30 | 30 | ACP fallback: some ACP agents accept the injected MCP server but fail to expose its tools. When the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call the same tools through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset when T3 runs as a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`. This is the supported T3 transport fallback, not an ordinary shell-based substitute for delegation. |
| 31 | ||
| 32 | ### Showing visuals | |
| 33 | ||
| 34 | When a chart, table, diagram, image collage, or mockup would say more than prose, build a self-contained HTML page, check it with \`html_preview\`, then publish it with \`html_render\` before your final reply. The reader sees the page above that reply, so don't announce or restate it; add only what it doesn't say. | |
| 31 | 35 | `; |
| 32 | 36 | |
| 33 | 37 | export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` |
apps/server/src/zipArchive.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type * as NodeStream from "node:stream"; | |
| 3 | import * as Yauzl from "yauzl"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Opens a local, already verified ZIP for lazy, one-entry-at-a-time reading. | |
| 7 | * `makeError` turns each failure into the caller's own error type. Close the | |
| 8 | * scope to close the archive. | |
| 9 | */ | |
| 10 | export const openZipArchive = Effect.fn("zipArchive.open")(function* <E>( | |
| 11 | archivePath: string, | |
| 12 | makeError: (detail: string, cause: unknown) => E, | |
| 13 | ) { | |
| 14 | const opened = yield* Effect.acquireRelease( | |
| 15 | Effect.callback< | |
| 16 | { | |
| 17 | readonly zip: Yauzl.ZipFile; | |
| 18 | readonly error: () => E | undefined; | |
| 19 | readonly close: Effect.Effect<void>; | |
| 20 | }, | |
| 21 | E | |
| 22 | >((resume) => { | |
| 23 | Yauzl.open( | |
| 24 | archivePath, | |
| 25 | { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, | |
| 26 | (error, zip) => { | |
| 27 | if (error || !zip) { | |
| 28 | resume(Effect.fail(makeError("Could not open the verified archive.", error))); | |
| 29 | return; | |
| 30 | } | |
| 31 | let closed = false; | |
| 32 | let archiveError: E | undefined; | |
| 33 | zip.on("close", () => { | |
| 34 | closed = true; | |
| 35 | }); | |
| 36 | zip.on("error", (cause: unknown) => { | |
| 37 | archiveError = makeError("The archive could not be read.", cause); | |
| 38 | }); | |
| 39 | resume( | |
| 40 | Effect.succeed({ | |
| 41 | zip, | |
| 42 | error: () => archiveError, | |
| 43 | close: Effect.callback<void>((finish) => { | |
| 44 | if (closed) { | |
| 45 | finish(Effect.void); | |
| 46 | return; | |
| 47 | } | |
| 48 | const onClose = () => { | |
| 49 | zip.removeListener("error", onError); | |
| 50 | finish(Effect.void); | |
| 51 | }; | |
| 52 | const onError = (cause: unknown) => { | |
| 53 | zip.removeListener("close", onClose); | |
| 54 | finish(Effect.die(makeError("Could not close the archive.", cause))); | |
| 55 | }; | |
| 56 | zip.once("close", onClose); | |
| 57 | zip.once("error", onError); | |
| 58 | zip.close(); | |
| 59 | }), | |
| 60 | }), | |
| 61 | ); | |
| 62 | }, | |
| 63 | ); | |
| 64 | }), | |
| 65 | (opened) => opened.close, | |
| 66 | ); | |
| 67 | ||
| 68 | const next = Effect.callback<Yauzl.Entry | null, E>((resume) => { | |
| 69 | const existingError = opened.error(); | |
| 70 | if (existingError) { | |
| 71 | resume(Effect.fail(existingError)); | |
| 72 | return; | |
| 73 | } | |
| 74 | const cleanup = () => { | |
| 75 | opened.zip.removeListener("entry", onEntry); | |
| 76 | opened.zip.removeListener("end", onEnd); | |
| 77 | opened.zip.removeListener("error", onError); | |
| 78 | }; | |
| 79 | const onEntry = (entry: Yauzl.Entry) => { | |
| 80 | cleanup(); | |
| 81 | resume(Effect.succeed(entry)); | |
| 82 | }; | |
| 83 | const onEnd = () => { | |
| 84 | cleanup(); | |
| 85 | resume(Effect.succeed(null)); | |
| 86 | }; | |
| 87 | const onError = (cause: unknown) => { | |
| 88 | cleanup(); | |
| 89 | resume(Effect.fail(makeError("The archive could not be read.", cause))); | |
| 90 | }; | |
| 91 | opened.zip.once("entry", onEntry); | |
| 92 | opened.zip.once("end", onEnd); | |
| 93 | opened.zip.once("error", onError); | |
| 94 | opened.zip.readEntry(); | |
| 95 | return Effect.sync(cleanup); | |
| 96 | }); | |
| 97 | ||
| 98 | const streamEntry = (entry: Yauzl.Entry) => | |
| 99 | Effect.acquireRelease( | |
| 100 | Effect.callback<NodeStream.Readable, E>((resume) => { | |
| 101 | opened.zip.openReadStream(entry, (cause, readable) => { | |
| 102 | resume( | |
| 103 | cause || !readable | |
| 104 | ? Effect.fail(makeError("Could not read an archive member.", cause)) | |
| 105 | : Effect.succeed(readable), | |
| 106 | ); | |
| 107 | }); | |
| 108 | }), | |
| 109 | (readable) => | |
| 110 | Effect.sync(() => { | |
| 111 | readable.destroy(); | |
| 112 | }), | |
| 113 | ); | |
| 114 | return { entryCount: opened.zip.entryCount, next, streamEntry }; | |
| 115 | }); |
apps/web/src/components/chat/HtmlRenderFrame.tsx created+130| ... | ... | @@ -0,0 +1,130 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | HTML_RENDER_COLUMN_WIDTH, | |
| 4 | htmlRenderFileName, | |
| 5 | htmlRenderFrameHeight, | |
| 6 | type HtmlRenderReference, | |
| 7 | } from "@t3tools/shared/htmlRender"; | |
| 8 | import { Maximize2Icon } from "lucide-react"; | |
| 9 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 10 | ||
| 11 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; | |
| 12 | import type { ChatFileAttachment } from "~/types"; | |
| 13 | ||
| 14 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; | |
| 15 | import { Button } from "../ui/button"; | |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 17 | ||
| 18 | // A frame may load its URL a little after mounting. | |
| 19 | const MIN_URL_LIFE_MS = 5 * 60_000; | |
| 20 | ||
| 21 | /** | |
| 22 | * An agent's HTML render inline in the thread: the page itself on the thread's | |
| 23 | * own background, at its measured height for this width (capped at the agent's | |
| 24 | * height). Loading and failure hold the same box so nothing below it moves. | |
| 25 | */ | |
| 26 | export function HtmlRenderFrame(props: { | |
| 27 | readonly environmentId: EnvironmentId; | |
| 28 | readonly htmlRender: HtmlRenderReference; | |
| 29 | readonly onOpen: (attachment: ChatFileAttachment) => void; | |
| 30 | }) { | |
| 31 | const { attachmentId, title } = props.htmlRender; | |
| 32 | // The frame takes the page's measured height at its own width, read before | |
| 33 | // first paint so the reserved box is already the right size. | |
| 34 | const boxRef = useRef<HTMLDivElement>(null); | |
| 35 | const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); | |
| 36 | useLayoutEffect(() => { | |
| 37 | const box = boxRef.current; | |
| 38 | if (!box) return; | |
| 39 | setWidth(box.clientWidth); | |
| 40 | const observer = new ResizeObserver(([entry]) => { | |
| 41 | if (entry) setWidth(entry.contentRect.width); | |
| 42 | }); | |
| 43 | observer.observe(box); | |
| 44 | return () => observer.disconnect(); | |
| 45 | }, []); | |
| 46 | const height = htmlRenderFrameHeight(props.htmlRender, width); | |
| 47 | const fileName = htmlRenderFileName(title); | |
| 48 | const resource = useMemo( | |
| 49 | () => ({ | |
| 50 | _tag: "attachment" as const, | |
| 51 | attachmentId, | |
| 52 | fileName, | |
| 53 | mimeType: "text/html", | |
| 54 | disposition: "inline" as const, | |
| 55 | }), | |
| 56 | [attachmentId, fileName], | |
| 57 | ); | |
| 58 | // A cached URL with life left is reused, so the browser's cache serves the | |
| 59 | // page again; one near expiry is minted afresh, since a frame cannot report a | |
| 60 | // failed load. The page keeps its first URL for its lifetime. | |
| 61 | const [src, setSrc] = useState<string | null>(null); | |
| 62 | const [failed, setFailed] = useState(false); | |
| 63 | const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource); | |
| 64 | const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null; | |
| 65 | const cachedExpiresAt = assetUrl._tag === "Success" ? assetUrl.expiresAt : 0; | |
| 66 | const cacheFailed = assetUrl._tag === "Failure"; | |
| 67 | const refresh = useAssetUrlRefresh(props.environmentId, resource); | |
| 68 | // At most one mint per mount: expiry is server time and the check uses the | |
| 69 | // client clock, so a skewed clock must not mint again on every update. | |
| 70 | const minting = useRef(false); | |
| 71 | useEffect(() => { | |
| 72 | if (src !== null || minting.current) return; | |
| 73 | if (cacheFailed) { | |
| 74 | // oxlint-disable-next-line react/set-state-in-effect -- Mirrors the cached URL's failure. | |
| 75 | setFailed(true); | |
| 76 | return; | |
| 77 | } | |
| 78 | if (cachedUrl === null) return; | |
| 79 | if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) { | |
| 80 | setSrc(cachedUrl); | |
| 81 | return; | |
| 82 | } | |
| 83 | minting.current = true; | |
| 84 | void refresh().then( | |
| 85 | (url) => (url === null ? setFailed(true) : setSrc(url)), | |
| 86 | () => setFailed(true), | |
| 87 | ); | |
| 88 | }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]); | |
| 89 | ||
| 90 | return ( | |
| 91 | <div ref={boxRef} className="group/html-render relative" style={{ height }}> | |
| 92 | {src !== null ? ( | |
| 93 | <> | |
| 94 | <HtmlRenderDocument src={src} title={title} className="block size-full" /> | |
| 95 | <div className="absolute end-2 top-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100"> | |
| 96 | <Tooltip> | |
| 97 | <TooltipTrigger | |
| 98 | render={ | |
| 99 | <Button | |
| 100 | aria-label="Open in panel" | |
| 101 | size="icon-xs" | |
| 102 | variant="glass" | |
| 103 | onClick={() => | |
| 104 | props.onOpen({ | |
| 105 | type: "file", | |
| 106 | id: attachmentId, | |
| 107 | name: fileName, | |
| 108 | mimeType: "text/html", | |
| 109 | // Unknown here; the preview leaves it out. | |
| 110 | sizeBytes: 0, | |
| 111 | htmlRender: true, | |
| 112 | }) | |
| 113 | } | |
| 114 | /> | |
| 115 | } | |
| 116 | > | |
| 117 | <Maximize2Icon className="size-3.5" /> | |
| 118 | </TooltipTrigger> | |
| 119 | <TooltipPopup side="left">Open in panel</TooltipPopup> | |
| 120 | </Tooltip> | |
| 121 | </div> | |
| 122 | </> | |
| 123 | ) : failed ? ( | |
| 124 | <p className="flex size-full items-center justify-center text-muted-foreground text-xs"> | |
| 125 | Unable to load {title} | |
| 126 | </p> | |
| 127 | ) : null} | |
| 128 | </div> | |
| 129 | ); | |
| 130 | } |
apps/web/src/components/chat/MessagesTimeline.logic.ts+26| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | type T3McpToolPresentation, |
| 49 | 49 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 50 | 50 | import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; |
| 51 | import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender"; | |
| 51 | 52 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; |
| 52 | 53 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 53 | 54 | import { |
| ... | ... | @@ -64,6 +65,7 @@ function timelineEntryRunId(entry: TimelineEntry): RunId | null { |
| 64 | 65 | if (entry.kind === "proposed-plan") { |
| 65 | 66 | return entry.proposedPlan.runId; |
| 66 | 67 | } |
| 68 | if (entry.kind === "html-render") return entry.runId; | |
| 67 | 69 | return entry.kind === "work" ? (entry.entry.runId ?? null) : null; |
| 68 | 70 | } |
| 69 | 71 | |
| ... | ... | @@ -587,6 +589,12 @@ type MessagesTimelineRowContent = |
| 587 | 589 | id: string; |
| 588 | 590 | createdAt: string; |
| 589 | 591 | proposedPlan: ProposedPlan; |
| 592 | } | |
| 593 | | { | |
| 594 | kind: "html-render"; | |
| 595 | id: string; | |
| 596 | createdAt: string; | |
| 597 | htmlRender: HtmlRenderReference; | |
| 590 | 598 | }; |
| 591 | 599 | |
| 592 | 600 | export interface StableMessagesTimelineRowsState { |
| ... | ... | @@ -708,6 +716,8 @@ function deriveSupersededAttemptFolds( |
| 708 | 716 | entry.attempt?.status !== "superseded" || |
| 709 | 717 | unfoldedRunIds.has(entry.attempt.runId) || |
| 710 | 718 | (entry.kind === "message" && entry.message.role === "user") || |
| 719 | // A published page stays visible, as it does when its turn folds. | |
| 720 | entry.kind === "html-render" || | |
| 711 | 721 | timelineEntryIsPersistentResourceCard(entry) || |
| 712 | 722 | (entry.kind === "work" && entry.entry.itemType === "system_notice") |
| 713 | 723 | ) { |
| ... | ... | @@ -1606,6 +1616,16 @@ export function deriveMessagesTimelineRows(input: { |
| 1606 | 1616 | continue; |
| 1607 | 1617 | } |
| 1608 | 1618 | |
| 1619 | if (timelineEntry.kind === "html-render") { | |
| 1620 | nextRows.push({ | |
| 1621 | kind: "html-render", | |
| 1622 | id: timelineEntry.id, | |
| 1623 | createdAt: timelineEntry.createdAt, | |
| 1624 | htmlRender: timelineEntry.htmlRender, | |
| 1625 | }); | |
| 1626 | continue; | |
| 1627 | } | |
| 1628 | ||
| 1609 | 1629 | if (timelineEntry.kind === "event") { |
| 1610 | 1630 | const previous = nextRows.at(-1); |
| 1611 | 1631 | if ( |
| ... | ... | @@ -2016,6 +2036,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 2016 | 2036 | case "proposed-plan": |
| 2017 | 2037 | return a.proposedPlan === (b as typeof a).proposedPlan; |
| 2018 | 2038 | |
| 2039 | case "html-render": { | |
| 2040 | // Entries rebuild on any tool update; an equal page must keep its mounted frame. | |
| 2041 | const bh = b as typeof a; | |
| 2042 | return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender); | |
| 2043 | } | |
| 2044 | ||
| 2019 | 2045 | case "event": |
| 2020 | 2046 | return ( |
| 2021 | 2047 | a.projectedItem === (b as typeof a).projectedItem && |
apps/web/src/components/chat/MessagesTimeline.tsx+20-1| ... | ... | @@ -168,6 +168,7 @@ import { |
| 168 | 168 | SnapShotAttachmentDetails, |
| 169 | 169 | } from "./SnapShotAttachmentDetails"; |
| 170 | 170 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 171 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; | |
| 171 | 172 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 172 | 173 | import { useFileContextMenuHandler } from "../../fileContextMenu"; |
| 173 | 174 | import { useProject, useThreadShell } from "../../state/entities"; |
| ... | ... | @@ -1779,7 +1780,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1779 | 1780 | !row.showAssistantMeta) || |
| 1780 | 1781 | row.kind === "worktree-setup" || |
| 1781 | 1782 | row.kind === "event" || |
| 1782 | row.kind === "attempt-fold" | |
| 1783 | row.kind === "attempt-fold" || | |
| 1784 | row.kind === "html-render" | |
| 1783 | 1785 | ? "pb-2" |
| 1784 | 1786 | : "pb-4", |
| 1785 | 1787 | (row.kind === "message" && row.message.role === "assistant") || |
| ... | ... | @@ -1827,6 +1829,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1827 | 1829 | ) : null} |
| 1828 | 1830 | {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null} |
| 1829 | 1831 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1832 | {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null} | |
| 1830 | 1833 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1831 | 1834 | {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null} |
| 1832 | 1835 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} |
| ... | ... | @@ -2700,6 +2703,22 @@ function ProposedPlanTimelineRow({ |
| 2700 | 2703 | ); |
| 2701 | 2704 | } |
| 2702 | 2705 | |
| 2706 | function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) { | |
| 2707 | const ctx = use(TimelineRowCtx); | |
| 2708 | ||
| 2709 | return ( | |
| 2710 | <div className="min-w-0 px-1"> | |
| 2711 | <HtmlRenderFrame | |
| 2712 | // A recycled row must not keep another page's frozen frame. | |
| 2713 | key={row.htmlRender.attachmentId} | |
| 2714 | environmentId={ctx.activeThreadEnvironmentId} | |
| 2715 | htmlRender={row.htmlRender} | |
| 2716 | onOpen={ctx.onFileOpen} | |
| 2717 | /> | |
| 2718 | </div> | |
| 2719 | ); | |
| 2720 | } | |
| 2721 | ||
| 2703 | 2722 | type V2EventTone = "muted" | "warning" | "danger" | "success"; |
| 2704 | 2723 | |
| 2705 | 2724 | function v2EventPresentation(item: OrchestrationV2TurnItem): { |
apps/web/src/components/files/AttachmentFilePreview.tsx+16-5| ... | ... | @@ -55,9 +55,12 @@ function renderedToggleLabel(mode: "markdown" | "html" | "table", rendered: bool |
| 55 | 55 | export function AttachmentFilePreview(props: { |
| 56 | 56 | name: string; |
| 57 | 57 | mimeType: string; |
| 58 | /** Zero when unknown. */ | |
| 58 | 59 | sizeBytes: number; |
| 59 | 60 | file?: Blob | null; |
| 60 | 61 | asset?: { environmentId: EnvironmentId; attachmentId: string }; |
| 62 | /** An agent's HTML render, shown in the app theme. */ | |
| 63 | htmlRender?: boolean; | |
| 61 | 64 | /** First crumb: where the file comes from. */ |
| 62 | 65 | origin?: string; |
| 63 | 66 | onRemove?: () => void; |
| ... | ... | @@ -242,7 +245,12 @@ export function AttachmentFilePreview(props: { |
| 242 | 245 | <ReadOnlySourcePreview name={props.name} text={content.text} /> |
| 243 | 246 | ) |
| 244 | 247 | ) : kind === "pdf" || kind === "html" ? ( |
| 245 | <BrowserDocumentFrame src={url} title={props.name} pdf={kind === "pdf"} /> | |
| 248 | <BrowserDocumentFrame | |
| 249 | src={url} | |
| 250 | title={props.name} | |
| 251 | pdf={kind === "pdf"} | |
| 252 | htmlRender={props.htmlRender === true} | |
| 253 | /> | |
| 246 | 254 | ) : kind === "audio" ? ( |
| 247 | 255 | <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} /> |
| 248 | 256 | ) : kind === "video" ? ( |
| ... | ... | @@ -286,9 +294,11 @@ export function AttachmentFilePreview(props: { |
| 286 | 294 | <span aria-current="page" className="min-w-0 truncate px-0.5 font-medium text-foreground"> |
| 287 | 295 | {props.name} |
| 288 | 296 | </span> |
| 289 | <span className="ml-2 shrink-0 text-muted-foreground"> | |
| 290 | {formatAttachmentSize(props.sizeBytes)} | |
| 291 | </span> | |
| 297 | {props.sizeBytes > 0 ? ( | |
| 298 | <span className="ml-2 shrink-0 text-muted-foreground"> | |
| 299 | {formatAttachmentSize(props.sizeBytes)} | |
| 300 | </span> | |
| 301 | ) : null} | |
| 292 | 302 | </div> |
| 293 | 303 | {renderedMode ? ( |
| 294 | 304 | <FileSurfaceAction |
| ... | ... | @@ -341,7 +351,8 @@ export function AttachmentFilePreview(props: { |
| 341 | 351 | </div> |
| 342 | 352 | {content?.truncated ? ( |
| 343 | 353 | <FileSurfaceNotice> |
| 344 | Preview limited to the first 1 MB of a {props.sizeBytes.toLocaleString()} byte file. Save | |
| 354 | Preview limited to the first 1 MB | |
| 355 | {props.sizeBytes > 0 ? ` of a ${props.sizeBytes.toLocaleString()} byte file` : ""}. Save | |
| 345 | 356 | the file to read it in full. |
| 346 | 357 | </FileSurfaceNotice> |
| 347 | 358 | ) : null} |
apps/web/src/components/files/BrowserDocumentFrame.tsx+86-1| ... | ... | @@ -1,3 +1,14 @@ |
| 1 | import { | |
| 2 | htmlRenderThemeFragment, | |
| 3 | htmlRenderThemeMessage, | |
| 4 | htmlRenderResult, | |
| 5 | readHtmlRenderLinkRequest, | |
| 6 | } from "@t3tools/shared/htmlRender"; | |
| 7 | import { useEffect, useRef, useState } from "react"; | |
| 8 | ||
| 9 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; | |
| 10 | import { cn } from "~/lib/utils"; | |
| 11 | ||
| 1 | 12 | /** |
| 2 | 13 | * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small |
| 3 | 14 | * zoom. The panel header is the only chrome we want, so ask for the page |
| ... | ... | @@ -13,11 +24,13 @@ export const isPdfPreviewFile = (path: string): boolean => |
| 13 | 24 | * Renders an HTML or PDF document from its URL. HTML runs in a sandboxed frame |
| 14 | 25 | * with an opaque origin, so a page cannot reach the app's session or storage. |
| 15 | 26 | * The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts. |
| 27 | * An agent's HTML render also wears the app theme. | |
| 16 | 28 | */ |
| 17 | 29 | export function BrowserDocumentFrame(props: { |
| 18 | 30 | readonly src: string; |
| 19 | 31 | readonly title: string; |
| 20 | 32 | readonly pdf: boolean; |
| 33 | readonly htmlRender?: boolean; | |
| 21 | 34 | }) { |
| 22 | 35 | const className = "min-h-0 flex-1 border-0 bg-white"; |
| 23 | 36 | return props.pdf ? ( |
| ... | ... | @@ -28,13 +41,85 @@ export function BrowserDocumentFrame(props: { |
| 28 | 41 | title={props.title} |
| 29 | 42 | className={className} |
| 30 | 43 | /> |
| 44 | ) : props.htmlRender ? ( | |
| 45 | <HtmlRenderDocument | |
| 46 | key={props.src} | |
| 47 | src={props.src} | |
| 48 | title={props.title} | |
| 49 | className="min-h-0 flex-1" | |
| 50 | /> | |
| 31 | 51 | ) : ( |
| 32 | 52 | <iframe |
| 33 | 53 | key={props.src} |
| 34 | 54 | src={props.src} |
| 35 | 55 | title={props.title} |
| 36 | 56 | className={className} |
| 37 | sandbox="allow-scripts allow-forms allow-popups allow-modals" | |
| 57 | sandbox="allow-scripts allow-forms allow-popups" | |
| 58 | /> | |
| 59 | ); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * A sandboxed agent HTML render in the app theme. The page reads the theme from | |
| 64 | * its URL fragment before first paint, then follows changes posted to its | |
| 65 | * bootstrap. The first URL is kept for the frame's lifetime: signed asset URLs | |
| 66 | * re-mint while it stays mounted, and a new src would reload the page. | |
| 67 | */ | |
| 68 | export function HtmlRenderDocument(props: { | |
| 69 | readonly src: string; | |
| 70 | readonly title: string; | |
| 71 | readonly className?: string; | |
| 72 | }) { | |
| 73 | const theme = useHtmlRenderTheme(); | |
| 74 | const frameRef = useRef<HTMLIFrameElement>(null); | |
| 75 | const [src] = useState(() => `${props.src.split("#", 1)[0]}${htmlRenderThemeFragment(theme)}`); | |
| 76 | const [loaded, setLoaded] = useState(false); | |
| 77 | const postTheme = () => { | |
| 78 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); | |
| 79 | }; | |
| 80 | useEffect(postTheme, [theme]); | |
| 81 | // The page cannot open windows itself (an inline page runs unopened, and | |
| 82 | // desktop sends any window to the browser). It asks the client, which opens | |
| 83 | // the link only while this frame has focus and the reader has just used the | |
| 84 | // app. A page can take focus by script, so this stops opens on load, not a | |
| 85 | // page that waits for the reader's next click or key. | |
| 86 | useEffect(() => { | |
| 87 | const openLink = (event: MessageEvent) => { | |
| 88 | const frame = frameRef.current; | |
| 89 | const request = readHtmlRenderLinkRequest(event.data); | |
| 90 | if ( | |
| 91 | request === undefined || | |
| 92 | frame === null || | |
| 93 | event.source !== frame.contentWindow || | |
| 94 | document.activeElement !== frame || | |
| 95 | navigator.userActivation?.isActive === false | |
| 96 | ) { | |
| 97 | return; | |
| 98 | } | |
| 99 | window.open(request.url, "_blank", "noopener,noreferrer"); | |
| 100 | frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*"); | |
| 101 | }; | |
| 102 | window.addEventListener("message", openLink); | |
| 103 | return () => window.removeEventListener("message", openLink); | |
| 104 | }, []); | |
| 105 | return ( | |
| 106 | <iframe | |
| 107 | ref={frameRef} | |
| 108 | src={src} | |
| 109 | title={props.title} | |
| 110 | // Never allow-same-origin: the opaque origin keeps the page out of the app's session. | |
| 111 | sandbox="allow-scripts allow-forms" | |
| 112 | loading="lazy" | |
| 113 | onLoad={() => { | |
| 114 | setLoaded(true); | |
| 115 | // Covers a theme change that landed while the page was loading. | |
| 116 | postTheme(); | |
| 117 | }} | |
| 118 | // A frame whose color scheme differs from its document's paints an opaque | |
| 119 | // canvas, so the blank document a frame starts with would flash white in | |
| 120 | // dark mode. Once the page is in, its prefers-color-scheme follows the app. | |
| 121 | className={cn("border-0 scheme-light", props.className)} | |
| 122 | style={loaded ? { colorScheme: theme.appearance } : undefined} | |
| 38 | 123 | /> |
| 39 | 124 | ); |
| 40 | 125 | } |
apps/web/src/components/files/FilePreviewPanel.tsx+2-1| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | 2 | import type { |
| 3 | ChatFileAttachment, | |
| 4 | 3 | EditorId, |
| 5 | 4 | EnvironmentId, |
| 6 | 5 | ResolvedKeybindingsConfig, |
| ... | ... | @@ -40,6 +39,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh |
| 40 | 39 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 41 | 40 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 42 | 41 | import { cn } from "~/lib/utils"; |
| 42 | import type { ChatFileAttachment } from "~/types"; | |
| 43 | 43 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 44 | 44 | import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; |
| 45 | 45 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| ... | ... | @@ -1193,6 +1193,7 @@ export default function FilePreviewPanel({ |
| 1193 | 1193 | mimeType={attachment.mimeType} |
| 1194 | 1194 | sizeBytes={attachment.sizeBytes} |
| 1195 | 1195 | asset={{ environmentId, attachmentId: attachment.id }} |
| 1196 | htmlRender={attachment.htmlRender === true} | |
| 1196 | 1197 | /> |
| 1197 | 1198 | ) : relativePath && isVideo && absolutePath ? ( |
| 1198 | 1199 | <WorkspaceVideoPreview |
apps/web/src/hooks/useHtmlRenderTheme.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender"; | |
| 2 | import { useMemo, useSyncExternalStore } from "react"; | |
| 3 | ||
| 4 | import { appearanceFontStack } from "../appearanceFonts"; | |
| 5 | import { | |
| 6 | getStandardThemeColors, | |
| 7 | getThemeColorsForMode, | |
| 8 | getThemeDefinition, | |
| 9 | resolveThemeHalf, | |
| 10 | subscribeToCustomThemes, | |
| 11 | type ThemeAppearance, | |
| 12 | type ThemeHalves, | |
| 13 | type ThemePreference, | |
| 14 | } from "../themePalette"; | |
| 15 | import { useClientSettings } from "./useSettings"; | |
| 16 | import { useTheme } from "./useTheme"; | |
| 17 | ||
| 18 | /** The palette `applyTheme` paints for a preference, or the stock look when no theme applies. */ | |
| 19 | function resolveActiveThemeColors( | |
| 20 | theme: ThemePreference, | |
| 21 | halves: ThemeHalves | null, | |
| 22 | appearance: ThemeAppearance, | |
| 23 | ) { | |
| 24 | const definition = getThemeDefinition(resolveThemeHalf(theme, halves, appearance)); | |
| 25 | return definition === null | |
| 26 | ? getStandardThemeColors(appearance) | |
| 27 | : (getThemeColorsForMode(definition, appearance) ?? definition.colors); | |
| 28 | } | |
| 29 | ||
| 30 | /** The app's active theme and fonts, as handed to agent HTML renders. Stable until one changes. */ | |
| 31 | export function useHtmlRenderTheme() { | |
| 32 | const { theme, resolvedTheme, themeHalves } = useTheme(); | |
| 33 | // Custom and published palettes can be edited in place, under an unchanged preference. | |
| 34 | const colors = useSyncExternalStore( | |
| 35 | subscribeToCustomThemes, | |
| 36 | () => resolveActiveThemeColors(theme, themeHalves, resolvedTheme), | |
| 37 | () => getStandardThemeColors(resolvedTheme), | |
| 38 | ); | |
| 39 | const sans = useClientSettings((settings) => settings.fontFamilySans); | |
| 40 | const mono = useClientSettings((settings) => settings.fontFamilyCode); | |
| 41 | return useMemo( | |
| 42 | () => | |
| 43 | htmlRenderTheme(colors, resolvedTheme, { | |
| 44 | sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans), | |
| 45 | mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono), | |
| 46 | }), | |
| 47 | [colors, resolvedTheme, sans, mono], | |
| 48 | ); | |
| 49 | } |
apps/web/src/rightPanelStore.ts+2-6| ... | ... | @@ -8,17 +8,13 @@ |
| 8 | 8 | * workspace paths, and diff/files remain singleton surfaces. |
| 9 | 9 | */ |
| 10 | 10 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 11 | import { | |
| 12 | EnvironmentId, | |
| 13 | ThreadId, | |
| 14 | type ChatFileAttachment, | |
| 15 | type ScopedThreadRef, | |
| 16 | } from "@t3tools/contracts"; | |
| 11 | import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; | |
| 17 | 12 | import { create } from "zustand"; |
| 18 | 13 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 19 | 14 | |
| 20 | 15 | import { resolveStorage } from "./lib/storage"; |
| 21 | 16 | import type { ThreadPanelPresentation } from "./rightPanelLayout"; |
| 17 | import type { ChatFileAttachment } from "./types"; | |
| 22 | 18 | |
| 23 | 19 | const RIGHT_PANEL_KINDS = [ |
| 24 | 20 | "diff", |
apps/web/src/session-logic.test.ts+141| ... | ... | @@ -1211,6 +1211,147 @@ describe("native provider presentation in the v2 timeline", () => { |
| 1211 | 1211 | }); |
| 1212 | 1212 | }); |
| 1213 | 1213 | |
| 1214 | describe("HTML renders in the timeline", () => { | |
| 1215 | const runId = RunId.make("render-run"); | |
| 1216 | const at = (second: number) => | |
| 1217 | DateTime.makeUnsafe(`2026-09-04T12:00:${String(second).padStart(2, "0")}.000Z`); | |
| 1218 | const base = (id: string, second: number) => ({ | |
| 1219 | id: TurnItemId.make(id), | |
| 1220 | threadId: ThreadId.make("render-thread"), | |
| 1221 | runId, | |
| 1222 | nodeId: null, | |
| 1223 | providerThreadId: null, | |
| 1224 | providerTurnId: null, | |
| 1225 | nativeItemRef: null, | |
| 1226 | parentItemId: null, | |
| 1227 | ordinal: second, | |
| 1228 | status: "completed" as const, | |
| 1229 | title: null, | |
| 1230 | startedAt: at(second), | |
| 1231 | completedAt: at(second), | |
| 1232 | updatedAt: at(second), | |
| 1233 | }); | |
| 1234 | const visible = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ | |
| 1235 | position: item.ordinal, | |
| 1236 | visibility: "local", | |
| 1237 | sourceThreadId: item.threadId, | |
| 1238 | sourceItemId: item.id, | |
| 1239 | item, | |
| 1240 | }); | |
| 1241 | const htmlRender = { attachmentId: "render-thread-chart.html", title: "Chart", height: 420 }; | |
| 1242 | const renderCall = ( | |
| 1243 | status: OrchestrationV2TurnItem["status"], | |
| 1244 | output?: unknown, | |
| 1245 | ): OrchestrationV2TurnItem => ({ | |
| 1246 | ...base("render", 2), | |
| 1247 | status, | |
| 1248 | type: "dynamic_tool", | |
| 1249 | toolName: "mcp__t3-code__html_render", | |
| 1250 | input: { title: "Chart", height: 420 }, | |
| 1251 | ...(output === undefined ? {} : { output }), | |
| 1252 | }); | |
| 1253 | const command = (id: string, second: number): OrchestrationV2TurnItem => ({ | |
| 1254 | ...base(id, second), | |
| 1255 | type: "command_execution", | |
| 1256 | input: "vp test run", | |
| 1257 | }); | |
| 1258 | const turn = (render: OrchestrationV2TurnItem) => { | |
| 1259 | const items: OrchestrationV2TurnItem[] = [ | |
| 1260 | { | |
| 1261 | ...base("prompt", 0), | |
| 1262 | type: "user_message", | |
| 1263 | messageId: MessageId.make("prompt"), | |
| 1264 | inputIntent: "turn_start", | |
| 1265 | text: "Chart it", | |
| 1266 | createdBy: "user", | |
| 1267 | creationSource: "server", | |
| 1268 | attachments: [], | |
| 1269 | }, | |
| 1270 | command("before", 1), | |
| 1271 | render, | |
| 1272 | command("after", 3), | |
| 1273 | { | |
| 1274 | ...base("reply", 4), | |
| 1275 | type: "assistant_message", | |
| 1276 | messageId: MessageId.make("reply"), | |
| 1277 | text: "Here it is.", | |
| 1278 | streaming: false, | |
| 1279 | }, | |
| 1280 | ]; | |
| 1281 | return deriveTimelineEntriesFromVisibleTurnItems({ | |
| 1282 | visibleTurnItems: items.map(visible), | |
| 1283 | optimisticMessages: [], | |
| 1284 | }); | |
| 1285 | }; | |
| 1286 | const rowsFor = (entries: TimelineEntry[], expanded: boolean) => | |
| 1287 | deriveMessagesTimelineRows({ | |
| 1288 | timelineEntries: entries, | |
| 1289 | latestRun: { | |
| 1290 | runId, | |
| 1291 | status: "completed", | |
| 1292 | startedAt: DateTime.formatIso(at(0)), | |
| 1293 | completedAt: DateTime.formatIso(at(5)), | |
| 1294 | }, | |
| 1295 | ...(expanded ? { expandedRunIds: new Set([runId]) } : {}), | |
| 1296 | isWorking: false, | |
| 1297 | activeTurnStartedAt: null, | |
| 1298 | turnDiffSummaries: [], | |
| 1299 | supportsConversationRollback: false, | |
| 1300 | }).map((row) => row.kind); | |
| 1301 | ||
| 1302 | it("shows a completed render in place, above the reply, through the turn fold", () => { | |
| 1303 | const entries = turn( | |
| 1304 | renderCall("completed", { structuredContent: { htmlRender }, content: [] }), | |
| 1305 | ); | |
| 1306 | expect(entries.find((entry) => entry.kind === "html-render")).toMatchObject({ | |
| 1307 | id: "render", | |
| 1308 | runId, | |
| 1309 | htmlRender, | |
| 1310 | }); | |
| 1311 | expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "html-render", "message"]); | |
| 1312 | expect(rowsFor(entries, true)).toEqual([ | |
| 1313 | "message", | |
| 1314 | "turn-fold", | |
| 1315 | "work", | |
| 1316 | "html-render", | |
| 1317 | "work", | |
| 1318 | "message", | |
| 1319 | ]); | |
| 1320 | }); | |
| 1321 | ||
| 1322 | it("keeps a render visible when its superseded attempt folds", () => { | |
| 1323 | const attempt: OrchestrationV2RunAttempt = { | |
| 1324 | id: RunAttemptId.make("attempt-superseded"), | |
| 1325 | runId, | |
| 1326 | attemptOrdinal: 1, | |
| 1327 | rootNodeId: NodeId.make("node-superseded"), | |
| 1328 | providerInstanceId: ProviderInstanceId.make("codex-default"), | |
| 1329 | providerThreadId: ProviderThreadId.make("provider-thread"), | |
| 1330 | providerTurnId: null, | |
| 1331 | reason: "initial", | |
| 1332 | status: "superseded", | |
| 1333 | startedAt: at(0), | |
| 1334 | completedAt: at(4), | |
| 1335 | }; | |
| 1336 | const entries = turn( | |
| 1337 | renderCall("completed", { structuredContent: { htmlRender }, content: [] }), | |
| 1338 | ).map((entry) => | |
| 1339 | entry.kind === "message" && entry.message.role === "user" ? entry : { ...entry, attempt }, | |
| 1340 | ); | |
| 1341 | expect(rowsFor(entries, true)).toEqual(["message", "turn-fold", "attempt-fold", "html-render"]); | |
| 1342 | }); | |
| 1343 | ||
| 1344 | it.each([ | |
| 1345 | ["running", "running", undefined], | |
| 1346 | ["failed", "failed", { content: [{ type: "text", text: "Invalid HTML" }], isError: true }], | |
| 1347 | ["errored", "completed", { structuredContent: { htmlRender }, isError: true }], | |
| 1348 | ] as const)("keeps a %s call in the work log", (_label, status, output) => { | |
| 1349 | const entries = turn(renderCall(status, output)); | |
| 1350 | expect(entries.some((entry) => entry.kind === "html-render")).toBe(false); | |
| 1351 | expect(entries.find((entry) => entry.id === "render")?.kind).toBe("work"); | |
| 1352 | }); | |
| 1353 | }); | |
| 1354 | ||
| 1214 | 1355 | describe("work-log failure policy (#7999/#7893)", () => { |
| 1215 | 1356 | const toolEntry = (overrides: Record<string, unknown>) => |
| 1216 | 1357 | ({ |
apps/web/src/session-logic.ts+26| ... | ... | @@ -20,6 +20,8 @@ import { |
| 20 | 20 | formatReadToolLabel, |
| 21 | 21 | formatSearchToolLabel, |
| 22 | 22 | } from "@t3tools/shared/toolActivity"; |
| 23 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; | |
| 24 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | |
| 23 | 25 | import { |
| 24 | 26 | contextCompactionLabel, |
| 25 | 27 | workEntryIndicatesToolFailure, |
| ... | ... | @@ -130,6 +132,14 @@ export type TimelineEntry = ( |
| 130 | 132 | readonly createdAt: string; |
| 131 | 133 | readonly proposedPlan: ProposedPlan; |
| 132 | 134 | } |
| 135 | | { | |
| 136 | /** A page a completed `html_render` call published, shown where the call happened. */ | |
| 137 | readonly id: string; | |
| 138 | readonly kind: "html-render"; | |
| 139 | readonly createdAt: string; | |
| 140 | readonly runId: RunId | null; | |
| 141 | readonly htmlRender: HtmlRenderReference; | |
| 142 | } | |
| 133 | 143 | | { |
| 134 | 144 | readonly id: string; |
| 135 | 145 | readonly kind: "work"; |
| ... | ... | @@ -685,6 +695,22 @@ export function deriveTimelineEntriesFromVisibleTurnItems( |
| 685 | 695 | continue; |
| 686 | 696 | } |
| 687 | 697 | |
| 698 | const htmlRender = | |
| 699 | item.type === "dynamic_tool" && item.status === "completed" | |
| 700 | ? htmlRenderFromToolItem(item) | |
| 701 | : undefined; | |
| 702 | if (htmlRender !== undefined) { | |
| 703 | entries.push({ | |
| 704 | id: item.id, | |
| 705 | kind: "html-render", | |
| 706 | createdAt, | |
| 707 | runId: item.runId, | |
| 708 | htmlRender, | |
| 709 | ...attemptMetadata, | |
| 710 | }); | |
| 711 | continue; | |
| 712 | } | |
| 713 | ||
| 688 | 714 | if (STANDALONE_V2_ITEM_TYPES.has(item.type)) { |
| 689 | 715 | entries.push({ |
| 690 | 716 | id: item.id, |
apps/web/src/types.ts+2| ... | ... | @@ -49,6 +49,8 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { |
| 49 | 49 | export interface ChatFileAttachment extends ContractChatFileAttachment { |
| 50 | 50 | readonly previewUrl?: string; |
| 51 | 51 | readonly downloadable?: boolean; |
| 52 | /** A page an agent published with `html_render`; its viewer hands it the app theme. */ | |
| 53 | readonly htmlRender?: boolean; | |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | // Attachment types this build does not know pass through with the contract |
docs/README.md+1| ... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 | - [Appearance and themes](./user/appearance.md) |
| 13 | 13 | - [Keyboard shortcuts](./user/keybindings.md) |
| 14 | 14 | - [SnapShots](./user/snap-shot.md) |
| 15 | - [Visual replies](./user/html-renders.md) | |
| 15 | 16 | - [Import browser sessions](./user/browser-import.md) |
| 16 | 17 | - [Devices](./user/devices.md) |
| 17 | 18 | - [Usage and limits](./user/usage.md) |
docs/user/html-renders.md created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | # Visual replies | |
| 2 | ||
| 3 | Agents can answer with a page instead of only text: a chart, table, diagram, image collage, or mockup. Ask for one ("show this as a chart", "make a collage of these screenshots") and the agent builds a self-contained HTML page, which appears in the thread above its written reply. It works with every provider, on web, desktop, and mobile. | |
| 4 | ||
| 5 | Pages use your current theme, including custom themes, and follow light and dark mode as you switch. Scripts run inside the page, but it is sandboxed away from T3 Code and your session. Links you click in a page open in your browser. Use the expand button to open a page full size; from there you can view its source or save it. | |
| 6 | ||
| 7 | Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages. | |
| 8 | ||
| 9 | Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Pages publish without it. |
packages/client-runtime/src/state/assets.ts+3| ... | ... | @@ -73,6 +73,8 @@ export type AssetUrlState = |
| 73 | 73 | | { |
| 74 | 74 | readonly _tag: "Success"; |
| 75 | 75 | readonly url: string; |
| 76 | /** When the signed URL stops working, in epoch milliseconds. */ | |
| 77 | readonly expiresAt: number; | |
| 76 | 78 | /** The host path the server chose to serve, when it differs from what was asked for. */ |
| 77 | 79 | readonly sourcePath?: string; |
| 78 | 80 | /** Pixel size from the image header, when the server could read one. */ |
| ... | ... | @@ -90,6 +92,7 @@ export function assetUrlStateFromResult( |
| 90 | 92 | return { |
| 91 | 93 | _tag: "Success", |
| 92 | 94 | url, |
| 95 | expiresAt: result.value.expiresAt, | |
| 93 | 96 | ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), |
| 94 | 97 | ...(result.value.imageDimensions !== undefined |
| 95 | 98 | ? { imageDimensions: result.value.imageDimensions } |
packages/client-runtime/src/t3ToolSummary.ts+6| ... | ... | @@ -395,6 +395,12 @@ export function summarizeT3ToolCalls( |
| 395 | 395 | case "device": |
| 396 | 396 | label = phrase("Used", "use", `device controls ${times}`); |
| 397 | 397 | break; |
| 398 | case "html-preview": | |
| 399 | label = phrase("Previewed", "preview", quantity(selected.length, "HTML page")); | |
| 400 | break; | |
| 401 | case "html-render": | |
| 402 | label = phrase("Rendered", "render", quantity(selected.length, "HTML page")); | |
| 403 | break; | |
| 398 | 404 | case "capabilities": |
| 399 | 405 | label = phrase("Checked", "check", `orchestration capabilities ${times}`); |
| 400 | 406 | break; |
packages/shared/package.json+4| ... | ... | @@ -115,6 +115,10 @@ |
| 115 | 115 | "types": "./src/toolOutput.ts", |
| 116 | 116 | "import": "./src/toolOutput.ts" |
| 117 | 117 | }, |
| 118 | "./htmlRender": { | |
| 119 | "types": "./src/htmlRender.ts", | |
| 120 | "import": "./src/htmlRender.ts" | |
| 121 | }, | |
| 118 | 122 | "./favicon": { |
| 119 | 123 | "types": "./src/favicon.ts", |
| 120 | 124 | "import": "./src/favicon.ts" |
packages/shared/src/htmlRender.test.ts created+209| ... | ... | @@ -0,0 +1,209 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | htmlRenderFrameHeight, | |
| 5 | htmlRenderReferencesEqual, | |
| 6 | htmlRenderTheme, | |
| 7 | htmlRenderThemeFragment, | |
| 8 | injectHtmlRenderBootstrap, | |
| 9 | htmlRenderThemeMessage, | |
| 10 | readHtmlRenderLinkRequest, | |
| 11 | readHtmlRenderReference, | |
| 12 | } from "./htmlRender.ts"; | |
| 13 | import { T3_CODE_DARK_THEME_COLORS, T3_CODE_LIGHT_THEME_COLORS } from "./themePalettes.ts"; | |
| 14 | import { htmlRenderFromToolItem } from "./toolOutput.ts"; | |
| 15 | ||
| 16 | const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; | |
| 17 | ||
| 18 | describe("injectHtmlRenderBootstrap", () => { | |
| 19 | it("puts the theme ahead of the page's own head content", () => { | |
| 20 | const html = | |
| 21 | "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>"; | |
| 22 | const injected = injectHtmlRenderBootstrap(html); | |
| 23 | const themeAt = injected.indexOf('<style id="t3-theme">'); | |
| 24 | expect(themeAt).toBeGreaterThan(injected.indexOf("<head>")); | |
| 25 | expect(themeAt).toBeLessThan(injected.indexOf(":root{--background:red}")); | |
| 26 | expect(injected).toContain('<meta charset="utf-8">'); | |
| 27 | expect(injected).toContain('name="viewport"'); | |
| 28 | }); | |
| 29 | ||
| 30 | it("wraps fragments without a head and keeps existing meta tags", () => { | |
| 31 | const fragment = | |
| 32 | '<meta charset="utf-8"><meta name="viewport" content="width=device-width"><p>hi</p>'; | |
| 33 | const injected = injectHtmlRenderBootstrap(fragment); | |
| 34 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); | |
| 35 | expect(injected.match(/charset/g)).toHaveLength(1); | |
| 36 | expect(injected.match(/name="viewport"/g)).toHaveLength(1); | |
| 37 | expect(injected.endsWith("<p>hi</p>")).toBe(true); | |
| 38 | }); | |
| 39 | ||
| 40 | it.each(["textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript", "plaintext"])( | |
| 41 | "keeps the bootstrap outside %s content", | |
| 42 | (tag) => { | |
| 43 | const fragment = `<${tag}><head><meta name="viewport"></head></${tag}>`; | |
| 44 | const injected = injectHtmlRenderBootstrap(fragment); | |
| 45 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); | |
| 46 | expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf(`<${tag}>`)); | |
| 47 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); | |
| 48 | expect(injected.endsWith(fragment)).toBe(true); | |
| 49 | }, | |
| 50 | ); | |
| 51 | ||
| 52 | it.each([ | |
| 53 | '<template><head><meta name="viewport"></head></template>', | |
| 54 | '<template><template>inner</template><head><meta name="viewport"></head></template>', | |
| 55 | ])("keeps the bootstrap outside inert template content: %s", (fragment) => { | |
| 56 | const injected = injectHtmlRenderBootstrap(fragment); | |
| 57 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); | |
| 58 | expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf("<template>")); | |
| 59 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); | |
| 60 | expect(injected.endsWith(fragment)).toBe(true); | |
| 61 | }); | |
| 62 | ||
| 63 | it("ignores tags written inside comments and scripts", () => { | |
| 64 | const html = | |
| 65 | '<!-- copy <head> and <meta name="viewport"> here --><html><head>' + | |
| 66 | "<script>const tag = '<meta name=\"viewport\">';</script></head><body>x</body></html>"; | |
| 67 | const injected = injectHtmlRenderBootstrap(html); | |
| 68 | expect(injected.indexOf('<style id="t3-theme">')).toBeGreaterThan( | |
| 69 | injected.indexOf("<html><head>"), | |
| 70 | ); | |
| 71 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); | |
| 72 | }); | |
| 73 | }); | |
| 74 | ||
| 75 | describe("readHtmlRenderLinkRequest", () => { | |
| 76 | it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => { | |
| 77 | const link = (url: unknown) => ({ | |
| 78 | jsonrpc: "2.0", | |
| 79 | id: 1, | |
| 80 | method: "ui/open-link", | |
| 81 | params: { url }, | |
| 82 | }); | |
| 83 | expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({ | |
| 84 | id: 1, | |
| 85 | url: "https://example.com/a", | |
| 86 | }); | |
| 87 | expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined(); | |
| 88 | expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined(); | |
| 89 | expect( | |
| 90 | readHtmlRenderLinkRequest({ | |
| 91 | jsonrpc: "2.0", | |
| 92 | method: "ui/open-link", | |
| 93 | params: { url: "https://example.com" }, | |
| 94 | }), | |
| 95 | ).toBeUndefined(); | |
| 96 | expect( | |
| 97 | readHtmlRenderLinkRequest({ type: "t3-html-render-link", url: "https://example.com" }), | |
| 98 | ).toBeUndefined(); | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | describe("htmlRenderThemeMessage", () => { | |
| 103 | it("is an MCP Apps host-context-changed notification carrying the theme variables", () => { | |
| 104 | const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | |
| 105 | expect(htmlRenderThemeMessage(theme)).toEqual({ | |
| 106 | jsonrpc: "2.0", | |
| 107 | method: "ui/notifications/host-context-changed", | |
| 108 | params: { theme: "dark", styles: { variables: theme.variables } }, | |
| 109 | }); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("htmlRenderTheme", () => { | |
| 114 | it("exposes the brand accent as --accent and keeps the fragment decodable", () => { | |
| 115 | const theme = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light"); | |
| 116 | expect(theme.variables["--accent"]).toBe(T3_CODE_LIGHT_THEME_COLORS.accent); | |
| 117 | expect(theme.variables["--chart-1"]).toBe(T3_CODE_LIGHT_THEME_COLORS.accent); | |
| 118 | expect(theme.variables["--chart-6"]).toBeDefined(); | |
| 119 | const fragment = htmlRenderThemeFragment(theme); | |
| 120 | expect(JSON.parse(decodeURIComponent(fragment.slice("#t3-theme=".length)))).toEqual(theme); | |
| 121 | expect(fragment).not.toContain("&"); | |
| 122 | expect(htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark").variables["--background"]).toBe( | |
| 123 | T3_CODE_DARK_THEME_COLORS.canvas, | |
| 124 | ); | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | describe("readHtmlRenderReference", () => { | |
| 129 | it("clamps height and rejects malformed references", () => { | |
| 130 | expect(readHtmlRenderReference({ ...reference, height: 99_999 })?.height).toBe(2000); | |
| 131 | expect(readHtmlRenderReference({ ...reference, title: " " })?.title).toBe("HTML"); | |
| 132 | expect(readHtmlRenderReference({ ...reference, attachmentId: 4 })).toBeUndefined(); | |
| 133 | expect(readHtmlRenderReference({ ...reference, height: Number.NaN })).toBeUndefined(); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | describe("htmlRenderFromToolItem", () => { | |
| 138 | const result = { htmlRender: reference, message: "Rendered above your reply." }; | |
| 139 | ||
| 140 | it("reads the reference from each provider's result envelope", () => { | |
| 141 | for (const [toolName, output] of [ | |
| 142 | ["mcp__t3-code__html_render", [{ type: "text", text: JSON.stringify(result) }]], | |
| 143 | ["t3-code.html_render", { structuredContent: result, content: [] }], | |
| 144 | ["t3-code-thread_1_html_render", JSON.stringify(result)], | |
| 145 | ["html_render", result], | |
| 146 | ] as const) { | |
| 147 | expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 151 | it("ignores other tools and failed calls", () => { | |
| 152 | expect(htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_preview", output: result })).toBe( | |
| 153 | undefined, | |
| 154 | ); | |
| 155 | expect(htmlRenderFromToolItem({ toolName: "mcp__other__html_render", output: result })).toBe( | |
| 156 | undefined, | |
| 157 | ); | |
| 158 | expect( | |
| 159 | htmlRenderFromToolItem({ toolName: "html_render", output: { ...result, isError: true } }), | |
| 160 | ).toBeUndefined(); | |
| 161 | }); | |
| 162 | }); | |
| 163 | ||
| 164 | describe("htmlRenderFrameHeight", () => { | |
| 165 | const measured = readHtmlRenderReference({ | |
| 166 | ...reference, | |
| 167 | height: 1500, | |
| 168 | heights: [ | |
| 169 | [728, 1403], | |
| 170 | [390, 1290], | |
| 171 | [1000, 1660], | |
| 172 | ], | |
| 173 | })!; | |
| 174 | ||
| 175 | it("takes the taller neighbor between measured widths and holds the ends", () => { | |
| 176 | expect(measured.heights?.map(([width]) => width)).toEqual([390, 728, 1000]); | |
| 177 | expect(htmlRenderFrameHeight(measured, 728)).toBe(1403); | |
| 178 | expect(htmlRenderFrameHeight(measured, 559)).toBe(1403); | |
| 179 | expect(htmlRenderFrameHeight(measured, 320)).toBe(1290); | |
| 180 | }); | |
| 181 | ||
| 182 | it("takes the taller layout when a breakpoint falls between measured widths", () => { | |
| 183 | // 900px tall below a 600px media query, 450px above it. | |
| 184 | const responsive = readHtmlRenderReference({ | |
| 185 | ...reference, | |
| 186 | height: 2000, | |
| 187 | heights: [ | |
| 188 | [520, 900], | |
| 189 | [640, 450], | |
| 190 | ], | |
| 191 | })!; | |
| 192 | expect(htmlRenderFrameHeight(responsive, 590)).toBe(900); | |
| 193 | expect(htmlRenderFrameHeight(responsive, 640)).toBe(450); | |
| 194 | }); | |
| 195 | ||
| 196 | it("never exceeds the agent's height and falls back to it without measurements", () => { | |
| 197 | expect(htmlRenderFrameHeight(measured, 1400)).toBe(1500); | |
| 198 | expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height); | |
| 199 | }); | |
| 200 | ||
| 201 | it("drops a malformed table and compares tables by value", () => { | |
| 202 | expect(readHtmlRenderReference({ ...reference, heights: [[728, "x"]] })?.heights).toBe( | |
| 203 | undefined, | |
| 204 | ); | |
| 205 | const copy = readHtmlRenderReference(JSON.parse(JSON.stringify(measured)))!; | |
| 206 | expect(htmlRenderReferencesEqual(measured, copy)).toBe(true); | |
| 207 | expect(htmlRenderReferencesEqual(measured, { ...copy, heights: [[390, 1290]] })).toBe(false); | |
| 208 | }); | |
| 209 | }); |
packages/shared/src/htmlRender.ts created+382| ... | ... | @@ -0,0 +1,382 @@ |
| 1 | import { | |
| 2 | T3_CODE_DARK_THEME_COLORS, | |
| 3 | T3_CODE_LIGHT_THEME_COLORS, | |
| 4 | type ThemeAppearance, | |
| 5 | type ThemeColors, | |
| 6 | } from "./themePalettes.ts"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Agent-authored HTML pages ("HTML renders") are self-contained documents an | |
| 10 | * agent publishes into a thread with T3's `html_render` MCP tool. The server | |
| 11 | * stores each one as a thread attachment with a small bootstrap injected into | |
| 12 | * its head; clients show it in a sandboxed iframe (web, desktop) or WebView | |
| 13 | * (mobile) and hand it the active theme as CSS custom properties. | |
| 14 | */ | |
| 15 | ||
| 16 | export const HTML_RENDER_TOOL_NAME = "html_render"; | |
| 17 | export const HTML_RENDER_MIN_HEIGHT = 80; | |
| 18 | export const HTML_RENDER_MAX_HEIGHT = 2000; | |
| 19 | export const HTML_RENDER_MAX_TITLE_LENGTH = 200; | |
| 20 | ||
| 21 | /** What the `html_render` tool result carries so clients can show the page. */ | |
| 22 | export interface HtmlRenderReference { | |
| 23 | readonly attachmentId: string; | |
| 24 | readonly title: string; | |
| 25 | /** The agent's frame height in CSS pixels, and the cap on any measured height. */ | |
| 26 | readonly height: number; | |
| 27 | /** | |
| 28 | * `[width, contentHeight]` pairs the server measured at publish, ascending by | |
| 29 | * width. Absent when the preview browser was not installed yet. | |
| 30 | */ | |
| 31 | readonly heights?: ReadonlyArray<readonly [width: number, height: number]>; | |
| 32 | } | |
| 33 | ||
| 34 | /** Frame widths the server measures a page at, from phones to the wide chat setting. */ | |
| 35 | export const HTML_RENDER_MEASURE_WIDTHS = [320, 375, 430, 520, 640, 728, 860, 1000, 1144] as const; | |
| 36 | ||
| 37 | /** | |
| 38 | * The reply column's frame width at the default chat width. Agents preview at | |
| 39 | * it, and it picks the measured height when a client cannot know its width. | |
| 40 | */ | |
| 41 | export const HTML_RENDER_COLUMN_WIDTH = 728; | |
| 42 | ||
| 43 | const MAX_MEASURED_HEIGHTS = 24; | |
| 44 | ||
| 45 | function readMeasuredHeights(value: unknown) { | |
| 46 | if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MEASURED_HEIGHTS) { | |
| 47 | return undefined; | |
| 48 | } | |
| 49 | const heights = value.flatMap((entry) => | |
| 50 | Array.isArray(entry) && | |
| 51 | entry.length === 2 && | |
| 52 | Number.isInteger(entry[0]) && | |
| 53 | entry[0] >= 1 && | |
| 54 | entry[0] <= 10_000 && | |
| 55 | typeof entry[1] === "number" && | |
| 56 | Number.isFinite(entry[1]) | |
| 57 | ? [[entry[0] as number, clampHtmlRenderHeight(entry[1])] as const] | |
| 58 | : [], | |
| 59 | ); | |
| 60 | return heights.length === value.length | |
| 61 | ? [...heights].sort((left, right) => left[0] - right[0]) | |
| 62 | : undefined; | |
| 63 | } | |
| 64 | ||
| 65 | export function readHtmlRenderReference(value: unknown): HtmlRenderReference | undefined { | |
| 66 | if (typeof value !== "object" || value === null) return undefined; | |
| 67 | const { attachmentId, title, height, heights } = value as Record<string, unknown>; | |
| 68 | if ( | |
| 69 | typeof attachmentId !== "string" || | |
| 70 | attachmentId.length === 0 || | |
| 71 | attachmentId.length > 256 || | |
| 72 | typeof title !== "string" || | |
| 73 | typeof height !== "number" || | |
| 74 | !Number.isFinite(height) | |
| 75 | ) { | |
| 76 | return undefined; | |
| 77 | } | |
| 78 | const measured = readMeasuredHeights(heights); | |
| 79 | return { | |
| 80 | attachmentId, | |
| 81 | title: title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML", | |
| 82 | height: clampHtmlRenderHeight(height), | |
| 83 | ...(measured === undefined ? {} : { heights: measured }), | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | /** Whether two references show the same page at the same sizes. */ | |
| 88 | export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: HtmlRenderReference) { | |
| 89 | return ( | |
| 90 | left.attachmentId === right.attachmentId && | |
| 91 | left.title === right.title && | |
| 92 | left.height === right.height && | |
| 93 | (left.heights ?? []).length === (right.heights ?? []).length && | |
| 94 | (left.heights ?? []).every( | |
| 95 | ([width, height], index) => | |
| 96 | right.heights?.[index]?.[0] === width && right.heights[index][1] === height, | |
| 97 | ) | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * The frame height for a page at a frame width: the taller of the heights | |
| 103 | * measured at the nearest widths on each side, capped at the agent's height. | |
| 104 | * A breakpoint between two measured widths can make the page as tall as | |
| 105 | * either, so the frame takes the taller. A page whose height grows with its | |
| 106 | * width inside one layout can still run slightly past the frame between | |
| 107 | * measured widths. | |
| 108 | */ | |
| 109 | export function htmlRenderFrameHeight(reference: HtmlRenderReference, width: number) { | |
| 110 | const heights = reference.heights; | |
| 111 | if (heights === undefined || heights.length === 0) return reference.height; | |
| 112 | const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width); | |
| 113 | const high = above === -1 ? heights.length - 1 : above; | |
| 114 | const low = heights[high]![0] === width ? high : Math.max(0, high - 1); | |
| 115 | const measured = Math.max(heights[low]![1], heights[high]![1]); | |
| 116 | return clampHtmlRenderHeight(Math.min(reference.height, measured)); | |
| 117 | } | |
| 118 | ||
| 119 | /** A readable download name: the title without characters file systems reject. */ | |
| 120 | export function htmlRenderFileName(title: string) { | |
| 121 | const name = title | |
| 122 | .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ") | |
| 123 | .replace(/\s+/g, " ") | |
| 124 | .trim() | |
| 125 | .slice(0, 120) | |
| 126 | .trim(); | |
| 127 | return `${name || "Page"}.html`; | |
| 128 | } | |
| 129 | ||
| 130 | export function clampHtmlRenderHeight(height: number): number { | |
| 131 | return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height))); | |
| 132 | } | |
| 133 | ||
| 134 | export interface HtmlRenderFonts { | |
| 135 | readonly sans: string; | |
| 136 | readonly mono: string; | |
| 137 | } | |
| 138 | ||
| 139 | export const HTML_RENDER_DEFAULT_FONTS: HtmlRenderFonts = { | |
| 140 | sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', | |
| 141 | // Concrete names only: some engines alias `ui-monospace` to the proportional UI font. | |
| 142 | mono: '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace', | |
| 143 | }; | |
| 144 | ||
| 145 | /** | |
| 146 | * Fonts the server's headless browser lays pages out with. Arial-metric faces | |
| 147 | * stand in for the system UI fonts readers see, so measured heights and | |
| 148 | * preview screenshots wrap text close to how a client will. | |
| 149 | */ | |
| 150 | export const HTML_RENDER_MEASURE_FONTS: HtmlRenderFonts = { | |
| 151 | sans: '"Liberation Sans", Arimo, Arial, Helvetica, sans-serif', | |
| 152 | mono: '"Liberation Mono", Cousine, Menlo, Consolas, monospace', | |
| 153 | }; | |
| 154 | ||
| 155 | /** The resolved theme a client hands an HTML render. */ | |
| 156 | export interface HtmlRenderTheme { | |
| 157 | readonly appearance: ThemeAppearance; | |
| 158 | readonly variables: Readonly<Record<string, string>>; | |
| 159 | } | |
| 160 | ||
| 161 | // Roles themes cannot change (index.css keeps info/success fixed), plus a | |
| 162 | // categorical chart series after the theme's own accent. | |
| 163 | const FIXED_COLORS = { | |
| 164 | light: { | |
| 165 | success: "#10b981", | |
| 166 | successForeground: "#047857", | |
| 167 | info: "#3b82f6", | |
| 168 | infoForeground: "#1d4ed8", | |
| 169 | chart: ["#0d9488", "#d97706", "#9333ea", "#e11d48", "#65a30d"], | |
| 170 | }, | |
| 171 | dark: { | |
| 172 | success: "#10b981", | |
| 173 | successForeground: "#34d399", | |
| 174 | info: "#3b82f6", | |
| 175 | infoForeground: "#60a5fa", | |
| 176 | chart: ["#2dd4bf", "#fbbf24", "#c084fc", "#fb7185", "#a3e635"], | |
| 177 | }, | |
| 178 | } as const; | |
| 179 | ||
| 180 | /** | |
| 181 | * Maps a theme palette to the variables HTML renders style against. Names | |
| 182 | * follow the app's own tokens, except `--accent`, which is the theme's brand | |
| 183 | * color here rather than the app's neutral hover surface. | |
| 184 | */ | |
| 185 | export function htmlRenderTheme( | |
| 186 | colors: ThemeColors, | |
| 187 | appearance: ThemeAppearance, | |
| 188 | fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS, | |
| 189 | ): HtmlRenderTheme { | |
| 190 | const fixed = FIXED_COLORS[appearance]; | |
| 191 | return { | |
| 192 | appearance, | |
| 193 | variables: { | |
| 194 | "--background": colors.canvas, | |
| 195 | "--foreground": colors.text, | |
| 196 | "--muted": colors.muted, | |
| 197 | "--muted-foreground": colors.mutedForeground, | |
| 198 | "--card": colors.surface, | |
| 199 | "--card-foreground": colors.text, | |
| 200 | "--popover": colors.surfaceOverlay, | |
| 201 | "--popover-foreground": colors.text, | |
| 202 | "--secondary": colors.secondary, | |
| 203 | "--secondary-foreground": colors.secondaryForeground, | |
| 204 | "--border": colors.border, | |
| 205 | "--input": colors.input, | |
| 206 | "--ring": colors.focus, | |
| 207 | "--primary": colors.messageAction, | |
| 208 | "--primary-foreground": colors.messageActionForeground, | |
| 209 | "--accent": colors.accent, | |
| 210 | "--accent-foreground": colors.accentForeground, | |
| 211 | "--accent-surface": colors.accentSurface, | |
| 212 | "--accent-surface-foreground": colors.accentSurfaceForeground, | |
| 213 | "--destructive": colors.error, | |
| 214 | "--destructive-foreground": colors.errorForeground, | |
| 215 | "--destructive-surface": colors.errorSurface, | |
| 216 | "--warning": colors.warning, | |
| 217 | "--warning-foreground": colors.warningForeground, | |
| 218 | "--warning-surface": colors.warningSurface, | |
| 219 | "--success": fixed.success, | |
| 220 | "--success-foreground": fixed.successForeground, | |
| 221 | "--info": fixed.info, | |
| 222 | "--info-foreground": fixed.infoForeground, | |
| 223 | "--code-background": colors.codeBackground, | |
| 224 | "--code-foreground": colors.codeForeground, | |
| 225 | "--chart-1": colors.accent, | |
| 226 | ...Object.fromEntries(fixed.chart.map((color, index) => [`--chart-${index + 2}`, color])), | |
| 227 | "--radius": "0.625rem", | |
| 228 | "--font-sans": fonts.sans, | |
| 229 | "--font-mono": fonts.mono, | |
| 230 | }, | |
| 231 | }; | |
| 232 | } | |
| 233 | ||
| 234 | /** Agent-facing reference for the injected variables, used in tool descriptions. */ | |
| 235 | export const HTML_RENDER_THEME_GUIDE = [ | |
| 236 | "T3 injects its active theme as CSS custom properties on :root, and they follow the user's theme and light/dark mode live:", | |
| 237 | "--background (page background, identical to the thread around the frame), --foreground, --muted, --muted-foreground,", | |
| 238 | "--card, --card-foreground, --popover, --popover-foreground, --secondary, --secondary-foreground, --border, --input, --ring,", | |
| 239 | "--primary, --primary-foreground (solid buttons), --accent, --accent-foreground (brand accent), --accent-surface, --accent-surface-foreground,", | |
| 240 | "--destructive, --destructive-foreground, --destructive-surface, --warning, --warning-foreground, --warning-surface,", | |
| 241 | "--success, --success-foreground, --info, --info-foreground, --code-background, --code-foreground,", | |
| 242 | "--chart-1 … --chart-6 (categorical series for charts), --radius, --font-sans, --font-mono.", | |
| 243 | "The base stylesheet sets html background/color/font from these, body margin to 0, and hides the page's scrollbar; your own CSS overrides it.", | |
| 244 | ].join(" "); | |
| 245 | ||
| 246 | /** Agent-facing layout rules for a page that sits inside a reply. */ | |
| 247 | export const HTML_RENDER_LAYOUT_GUIDE = [ | |
| 248 | `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`, | |
| 249 | "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.", | |
| 250 | "Give charts fixed pixel heights rather than heights that scale with width.", | |
| 251 | ].join(" "); | |
| 252 | ||
| 253 | // The bridge between a render and its client speaks the MCP Apps protocol | |
| 254 | // (JSON-RPC over postMessage), so the same host code can later drive upstream | |
| 255 | // MCP apps: https://github.com/modelcontextprotocol/ext-apps | |
| 256 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; | |
| 257 | const OPEN_LINK_METHOD = "ui/open-link"; | |
| 258 | ||
| 259 | /** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ | |
| 260 | export function readHtmlRenderLinkRequest( | |
| 261 | data: unknown, | |
| 262 | ): { readonly id: string | number; readonly url: string } | undefined { | |
| 263 | if (typeof data !== "object" || data === null) return undefined; | |
| 264 | const { jsonrpc, id, method, params } = data as Record<string, unknown>; | |
| 265 | if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined; | |
| 266 | if (typeof id !== "string" && typeof id !== "number") return undefined; | |
| 267 | const url = | |
| 268 | typeof params === "object" && params !== null ? (params as { url?: unknown }).url : undefined; | |
| 269 | return typeof url === "string" && /^https?:\/\//i.test(url) ? { id, url } : undefined; | |
| 270 | } | |
| 271 | ||
| 272 | /** The empty result a client sends back for a render's request. */ | |
| 273 | export function htmlRenderResult(id: string | number) { | |
| 274 | return { jsonrpc: "2.0", id, result: {} } as const; | |
| 275 | } | |
| 276 | ||
| 277 | const THEME_FRAGMENT_KEY = "t3-theme"; | |
| 278 | ||
| 279 | /** URL fragment that hands a render its theme before first paint. */ | |
| 280 | export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string { | |
| 281 | return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`; | |
| 282 | } | |
| 283 | ||
| 284 | /** The `host-context-changed` notification a client posts into a mounted render when the theme changes. */ | |
| 285 | export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { | |
| 286 | return { | |
| 287 | jsonrpc: "2.0", | |
| 288 | method: HOST_CONTEXT_CHANGED_METHOD, | |
| 289 | params: { theme: theme.appearance, styles: { variables: theme.variables } }, | |
| 290 | } as const; | |
| 291 | } | |
| 292 | ||
| 293 | // The frame scrolls a page taller than itself, but a scrollbar inside the | |
| 294 | // reply reads as a box within the thread, so it stays hidden. | |
| 295 | const BASE_CSS = | |
| 296 | "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + | |
| 297 | "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}"; | |
| 298 | ||
| 299 | function rootRule(theme: HtmlRenderTheme): string { | |
| 300 | const declarations = Object.entries(theme.variables) | |
| 301 | .map(([name, value]) => `${name}:${value};`) | |
| 302 | .join(""); | |
| 303 | return `:root{color-scheme:${theme.appearance};${declarations}}`; | |
| 304 | } | |
| 305 | ||
| 306 | // Runs synchronously in <head>, before the page's own styles and body, so the | |
| 307 | // first paint is already themed. It rewrites its own <style> element rather | |
| 308 | // than setting inline properties, so a page's later `:root` rules still win, | |
| 309 | // then drops the fragment so a page's own hash routing never sees it. A link | |
| 310 | // the reader clicks to another page never replaces the page inside the thread: | |
| 311 | // a framed page asks its client to open it, and a top-level page (mobile) | |
| 312 | // opens it as a new window, which the client sends to the browser. | |
| 313 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);})();`; | |
| 314 | ||
| 315 | function bootstrapMarkup(markup: string): string { | |
| 316 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | |
| 317 | const light = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light"); | |
| 318 | // Without a client-provided theme (a direct download, the headless preview | |
| 319 | // without a fragment) the page follows the OS appearance. | |
| 320 | const defaultCss = `${rootRule(dark)}@media (prefers-color-scheme: light){${rootRule(light)}}${BASE_CSS}`; | |
| 321 | return [ | |
| 322 | /<meta\s[^>]*charset/i.test(markup.slice(0, 4096)) ? "" : '<meta charset="utf-8">', | |
| 323 | /<meta\s[^>]*name\s*=\s*["']?viewport/i.test(markup) | |
| 324 | ? "" | |
| 325 | : '<meta name="viewport" content="width=device-width, initial-scale=1">', | |
| 326 | `<style id="t3-theme">${defaultCss}</style>`, | |
| 327 | `<script>${BOOTSTRAP_SCRIPT}</script>`, | |
| 328 | ].join(""); | |
| 329 | } | |
| 330 | ||
| 331 | // Comments, raw text, and template contents are blanked to the same length, | |
| 332 | // so offsets still line up and inert tags cannot receive the bootstrap. | |
| 333 | const blankNonMarkup = (html: string) => { | |
| 334 | const scan = html.replace( | |
| 335 | /<!--[\s\S]*?(?:-->|$)|<(script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)|<plaintext\b[\s\S]*$/gi, | |
| 336 | (match) => " ".repeat(match.length), | |
| 337 | ); | |
| 338 | const parts: string[] = []; | |
| 339 | let depth = 0; | |
| 340 | let start = 0; | |
| 341 | let at = 0; | |
| 342 | for (const match of scan.matchAll(/<(\/?)template(?:\s[^>]*)?\/?>/gi)) { | |
| 343 | if (!match[1]) { | |
| 344 | if (depth++ === 0) start = match.index; | |
| 345 | } else if (depth > 0 && --depth === 0) { | |
| 346 | const end = match.index + match[0].length; | |
| 347 | parts.push(scan.slice(at, start), " ".repeat(end - start)); | |
| 348 | at = end; | |
| 349 | } | |
| 350 | } | |
| 351 | if (depth > 0) { | |
| 352 | parts.push(scan.slice(at, start), " ".repeat(scan.length - start)); | |
| 353 | at = scan.length; | |
| 354 | } | |
| 355 | parts.push(scan.slice(at)); | |
| 356 | return parts.join(""); | |
| 357 | }; | |
| 358 | ||
| 359 | /** | |
| 360 | * Inserts the theme bootstrap at the start of the document head, so a page's | |
| 361 | * own styles and scripts come after it. | |
| 362 | */ | |
| 363 | export function injectHtmlRenderBootstrap(html: string): string { | |
| 364 | const scan = blankNonMarkup(html); | |
| 365 | const markup = bootstrapMarkup(scan); | |
| 366 | const headOpen = /<head(?:\s[^>]*)?>/i.exec(scan); | |
| 367 | if (headOpen) { | |
| 368 | const at = headOpen.index + headOpen[0].length; | |
| 369 | return html.slice(0, at) + markup + html.slice(at); | |
| 370 | } | |
| 371 | const htmlOpen = /<html(?:\s[^>]*)?>/i.exec(scan); | |
| 372 | if (htmlOpen) { | |
| 373 | const at = htmlOpen.index + htmlOpen[0].length; | |
| 374 | return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`; | |
| 375 | } | |
| 376 | const doctype = /^\s*<!doctype[^>]*>/i.exec(html); | |
| 377 | if (doctype) { | |
| 378 | const at = doctype[0].length; | |
| 379 | return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`; | |
| 380 | } | |
| 381 | return `<!doctype html><head>${markup}</head>${html}`; | |
| 382 | } |
packages/shared/src/t3McpToolPresentation.test.ts+7| ... | ... | @@ -87,6 +87,13 @@ describe("resolveT3McpToolPresentation", () => { |
| 87 | 87 | } |
| 88 | 88 | }); |
| 89 | 89 | |
| 90 | it("matches OpenCode 2's per-thread server names, whose thread ids hold underscores", () => { | |
| 91 | expect( | |
| 92 | resolveT3McpToolPresentation("t3-code-thread_opencode2-adapter_delegate_task")?.displayName, | |
| 93 | ).toBe("Delegate a child task"); | |
| 94 | expect(resolveT3McpToolPresentation("t3-code-thread_opencode2-adapter_not_a_tool")).toBeNull(); | |
| 95 | }); | |
| 96 | ||
| 90 | 97 | it("keeps unknown MCP tools on the generic renderer path", () => { |
| 91 | 98 | expect(resolveT3McpToolPresentation("mcp__github__search_issues")).toBeNull(); |
| 92 | 99 | expect(resolveT3McpToolPresentation("t3-code.not_a_real_tool")).toBeNull(); |
packages/shared/src/t3McpToolPresentation.ts+21-2| ... | ... | @@ -58,7 +58,9 @@ export type T3McpToolSummaryAction = |
| 58 | 58 | | "watch-pr" |
| 59 | 59 | | "unwatch-pr" |
| 60 | 60 | | "browser" |
| 61 | | "device"; | |
| 61 | | "device" | |
| 62 | | "html-preview" | |
| 63 | | "html-render"; | |
| 62 | 64 | |
| 63 | 65 | export interface T3McpToolDefinition { |
| 64 | 66 | readonly displayName: string; |
| ... | ... | @@ -284,6 +286,8 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { |
| 284 | 286 | "attachment-discard", |
| 285 | 287 | ), |
| 286 | 288 | t3_thread_send_attachments: tool(["Send", "Sending", "Sent", "attachments"], "attachment-send"), |
| 289 | html_preview: tool(["Preview", "Previewing", "Previewed", "an HTML page"], "html-preview"), | |
| 290 | html_render: tool(["Render", "Rendering", "Rendered", "an HTML page"], "html-render"), | |
| 287 | 291 | }; |
| 288 | 292 | |
| 289 | 293 | /** |
| ... | ... | @@ -324,7 +328,22 @@ function resolveT3McpToolName(value: string): string | null { |
| 324 | 328 | |
| 325 | 329 | const prefixed = /^(?:mcp[-_]{1,2})?t3[-_ ]?code(?:__|[-_.:/ ])(?<tool>.+)$/i.exec(label); |
| 326 | 330 | const candidate = prefixed?.groups?.tool ?? label; |
| 327 | return Object.hasOwn(T3_MCP_TOOLS, candidate) ? candidate : null; | |
| 331 | if (Object.hasOwn(T3_MCP_TOOLS, candidate)) return candidate; | |
| 332 | // OpenCode 2 registers one server per thread, `t3-code-<thread>`, and joins | |
| 333 | // it to the tool with `_`. Thread ids can hold `_` too, so take the longest | |
| 334 | // known tool name that ends the label. | |
| 335 | if (!/^t3-code-/i.test(label)) return null; | |
| 336 | let longest: string | null = null; | |
| 337 | for (const tool of Object.keys(T3_MCP_TOOLS)) { | |
| 338 | if (label.endsWith(`_${tool}`) && tool.length > (longest?.length ?? 0)) longest = tool; | |
| 339 | } | |
| 340 | return longest; | |
| 341 | } | |
| 342 | ||
| 343 | /** The bare T3 tool name (`html_render`) for any provider's spelling of it. */ | |
| 344 | export function resolveT3McpToolId(toolName: string | null | undefined): string | null { | |
| 345 | const name = toolName == null ? null : resolveT3McpToolName(toolName); | |
| 346 | return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? name : null; | |
| 328 | 347 | } |
| 329 | 348 | |
| 330 | 349 | export function resolveT3McpToolDefinition( |
packages/shared/src/toolOutput.ts+20| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | 1 | import { isProviderSendTurnSupportedImageMimeType } from "@t3tools/contracts"; |
| 2 | 2 | import * as Predicate from "effect/Predicate"; |
| 3 | 3 | |
| 4 | import { | |
| 5 | HTML_RENDER_TOOL_NAME, | |
| 6 | readHtmlRenderReference, | |
| 7 | type HtmlRenderReference, | |
| 8 | } from "./htmlRender.ts"; | |
| 9 | import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; | |
| 10 | ||
| 4 | 11 | const MAX_PARSED_BYTES = 16_384; |
| 5 | 12 | const MAX_METADATA_BYTES = 8_192; |
| 6 | 13 | const MAX_ID_LENGTH = 256; |
| ... | ... | @@ -28,6 +35,7 @@ interface CompactToolOutput { |
| 28 | 35 | taskId?: string; |
| 29 | 36 | scheduledTaskId?: string; |
| 30 | 37 | status?: "rolled_back"; |
| 38 | htmlRender?: HtmlRenderReference; | |
| 31 | 39 | thread?: { threadId: string }; |
| 32 | 40 | threads?: Array<{ threadId?: string; status?: "rolled_back" }>; |
| 33 | 41 | } |
| ... | ... | @@ -111,6 +119,8 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un |
| 111 | 119 | if (id !== undefined) output[key] = id; |
| 112 | 120 | } |
| 113 | 121 | if (data.status === "rolled_back") output.status = "rolled_back"; |
| 122 | const htmlRender = readHtmlRenderReference(data.htmlRender); | |
| 123 | if (htmlRender !== undefined) output.htmlRender = htmlRender; | |
| 114 | 124 | const nestedThreadId = Predicate.isObject(data.thread) |
| 115 | 125 | ? boundedId(data.thread.threadId) |
| 116 | 126 | : undefined; |
| ... | ... | @@ -154,6 +164,16 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un |
| 154 | 164 | return Object.keys(output).length === 0 ? undefined : output; |
| 155 | 165 | } |
| 156 | 166 | |
| 167 | /** The page a completed `html_render` tool call published, if this item is one. */ | |
| 168 | export function htmlRenderFromToolItem(item: { | |
| 169 | readonly toolName: string | null | undefined; | |
| 170 | readonly output?: unknown; | |
| 171 | }): HtmlRenderReference | undefined { | |
| 172 | if (resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME) return undefined; | |
| 173 | const output = compactDynamicToolOutput(item.output); | |
| 174 | return output?.isError ? undefined : output?.htmlRender; | |
| 175 | } | |
| 176 | ||
| 157 | 177 | /** Some providers report completion even when command output describes a failure. */ |
| 158 | 178 | export function toolOutputIndicatesFailure(text: string): boolean { |
| 159 | 179 | return ( |