authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 15:43:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:43:07-07:00
log677d1527c3d96b90b2294d9a0aed7d8c8874b29b
tree81b453897829e0c8997a59bba80f244bbb9994e3
parentc138163c8cc39d4a98b2d3d796bcef183013bac5
signature Signed by PGP key B5690EEEBB952194

feat: agents can show HTML pages inline in threads (#15968)

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";
2424import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2525import { SourceFileSurface } from "./SourceFileSurface";
2626import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
27import { HtmlRenderWebView } from "../threads/HtmlRenderWebView";
2728
2829/**
2930 * 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 = {
3839 readonly sizeBytes: string;
3940 /** Present for a draft attachment, which may still live only on this device. */
4041 readonly draftKey?: string;
42 /** Present for a page an agent published with `html_render`, which takes the app theme. */
43 readonly htmlRender?: "1";
4144};
4245
4346type AttachmentFileScreenProps = StaticScreenProps<AttachmentFileRouteParams>;
......@@ -54,6 +57,7 @@ function AttachmentDocumentBody(props: {
5457 readonly document: ReturnType<typeof useAttachmentDocument>;
5558 readonly name: string;
5659 readonly environmentId: EnvironmentId | null;
60 readonly htmlRender: boolean;
5761 readonly nativeViewer: "pending" | "open" | "unavailable" | null;
5862 readonly nativeError: string | null;
5963 readonly onOpenNative: () => void;
......@@ -141,7 +145,11 @@ function AttachmentDocumentBody(props: {
141145 return <AudioFilePreview key={document.revision} uri={document.uri} onRetry={document.retry} />;
142146 }
143147 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 );
145153 }
146154 return (
147155 <View className="flex-1 items-center justify-center bg-sheet px-6">
......@@ -339,7 +347,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
339347 },
340348 [menuActions],
341349 );
342 const subtitle = `${draftKey ? "Draft attachment" : "Attachment"} · ${formatAttachmentSize(sizeBytes)}`;
350 const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}`;
343351
344352 return (
345353 <View className="flex-1 bg-sheet">
......@@ -405,6 +413,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
405413 document={document}
406414 name={params.name}
407415 environmentId={environmentId}
416 htmlRender={params.htmlRender === "1"}
408417 nativeViewer={nativeViewer}
409418 nativeError={nativeError}
410419 onOpenNative={() => {
apps/mobile/src/features/threads/HtmlRenderWebView.tsx created+289
......@@ -0,0 +1,289 @@
1import { useNavigation } from "@react-navigation/native";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import {
4 htmlRenderFileName,
5 htmlRenderFrameHeight,
6 htmlRenderThemeFragment,
7 htmlRenderThemeMessage,
8 type HtmlRenderReference,
9 type HtmlRenderTheme,
10} from "@t3tools/shared/htmlRender";
11import { useEffect, useMemo, useRef, useState } from "react";
12import { ActivityIndicator, Platform, Pressable, View, type ColorValue } from "react-native";
13import { WebView, type WebViewMessageEvent } from "react-native-webview";
14
15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text } from "../../components/AppText";
17import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
19import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
20import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
21
22const ROW_BOTTOM_MARGIN = 8;
23const FULL_SCREEN_GUTTER = 16;
24
25/** A render row is its frame's height plus spacing; the page's content never sizes it. */
26export function htmlRenderRowHeight(frameHeight: number) {
27 return frameHeight + ROW_BOTTOM_MARGIN;
28}
29
30function 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
46function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) {
47 view?.injectJavaScript(
48 `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`,
49 );
50}
51
52const 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.
56const 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
58function 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
74const 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 */
80export 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. */
186export 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 {
183183import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
184184import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
185185import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
186import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";
187import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";
186188import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
187189import {
188190 assetEnvironment,
......@@ -1536,6 +1538,8 @@ function renderFeedEntry(
15361538 readonly userBubbleMaxWidth: number;
15371539 /** Width assistant markdown lays out in, so images can size their frame before layout. */
15381540 readonly markdownContentWidth: number;
1541 /** Width full-bleed rows (HTML renders) lay out in. */
1542 readonly contentWidth: number;
15391543 readonly threadTitle: string;
15401544 },
15411545) {
......@@ -1574,6 +1578,18 @@ function renderFeedEntry(
15741578 return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;
15751579 }
15761580
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
15771593 if (entry.type === "work-toggle") {
15781594 return (
15791595 <ThreadWorkGroupToggle
......@@ -2870,6 +2886,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
28702886 // cards and related-thread links can exceed the compact row height.
28712887 const getFixedItemSize = useCallback(
28722888 (entry: ThreadFeedEntry) => {
2889 if (entry.type === "html-render") {
2890 return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));
2891 }
28732892 if (workRowSizing.fixedRowHeight === undefined) {
28742893 return undefined;
28752894 }
......@@ -2903,8 +2922,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29032922 return undefined;
29042923 }
29052924 },
2906 [expandedWorkRows, workRowSizing.fixedRowHeight],
2925 [contentWidth, expandedWorkRows, workRowSizing.fixedRowHeight],
29072926 );
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]);
29082939
29092940 // Disclosures can mount existing offscreen rows as well as new work rows.
29102941 // Fade those in after movement; never retain removed rows over replacements.
......@@ -2947,6 +2978,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29472978 themeAppearance,
29482979 userBubbleMaxWidth,
29492980 markdownContentWidth,
2981 contentWidth,
29502982 threadTitle: props.threadTitle,
29512983 skills: props.skills,
29522984 workspaceRoot: props.workspaceRoot,
......@@ -2983,6 +3015,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29833015 themeAppearance,
29843016 userBubbleMaxWidth,
29853017 markdownContentWidth,
3018 contentWidth,
29863019 onCopyWorkRow,
29873020 markdownLinkHandlers,
29883021 onPressPreview,
apps/mobile/src/lib/htmlRenderTheme.test.ts created+40
......@@ -0,0 +1,40 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { mobileHtmlRenderTheme } from "./htmlRenderTheme";
4import { getMobileThemeVariables } from "./mobileTheme";
5
6describe("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 @@
1import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender";
2
3import {
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 */
18export 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 {
123123 : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
124124}
125125
126function nativeColors(colors: ThemeColors): ThemeColors {
126export function nativeColors(colors: ThemeColors): ThemeColors {
127127 return Object.fromEntries(
128128 Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]),
129129 ) as ThemeColors;
apps/mobile/src/lib/openExternalUrl.ts+1
......@@ -6,6 +6,7 @@ const ExternalUrlTarget = Schema.Literals([
66 "markdown-link",
77 "pull-request",
88 "provider-auth",
9 "html-render",
910]);
1011
1112export 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)(
23752375 });
23762376 },
23772377);
2378
2379describe("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 {
5757 formatSearchToolLabel,
5858} from "@t3tools/shared/toolActivity";
5959import { formatDuration } from "@t3tools/shared/orchestrationTiming";
60import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
60import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
61import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
6162import * as DateTime from "effect/DateTime";
6263
6364export type PendingApproval = ThreadPendingApproval;
......@@ -155,12 +156,20 @@ type RawThreadFeedEntry =
155156 readonly createdAt: string;
156157 readonly runId: RunId | null;
157158 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;
158167 };
159168
160169export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean };
161170
162171type ThreadFeedEntryContent =
163 | Extract<RawThreadFeedEntry, { type: "message" }>
172 | Extract<RawThreadFeedEntry, { type: "message" | "html-render" }>
164173 | {
165174 readonly type: "activity-group";
166175 readonly id: string;
......@@ -1020,7 +1029,7 @@ function deriveThreadFeedRunFolds(
10201029 const runId =
10211030 entry.type === "message" && entry.message.role === "assistant"
10221031 ? (entry.message.runId ?? runlessKey)
1023 : entry.type === "activity-group"
1032 : entry.type === "activity-group" || entry.type === "html-render"
10241033 ? (entry.runId ?? runlessKey)
10251034 : null;
10261035 if (!runId) continue;
......@@ -1072,6 +1081,7 @@ function deriveThreadFeedRunFolds(
10721081 (entry) =>
10731082 entry.id !== firstAssistantId &&
10741083 entry.id !== terminalAssistantId &&
1084 entry.type !== "html-render" &&
10751085 !(
10761086 entry.type === "activity-group" &&
10771087 entry.activities.some(
......@@ -1134,7 +1144,7 @@ const trailingReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedA
11341144
11351145/** A steer or subsequent activity ends thinking even if the provider omits its completion. */
11361146function settleSupersededReasoning(
1137 entry: Extract<ThreadFeedEntry, { readonly type: "message" | "activity-group" }>,
1147 entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>,
11381148 tail: boolean,
11391149) {
11401150 if (entry.type !== "activity-group") return entry;
......@@ -1700,6 +1710,23 @@ export function buildThreadFeed(
17001710 continue;
17011711 }
17021712 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 }
17031730 if (item.type === "user_message" || item.type === "assistant_message") {
17041731 const updatedAt = DateTime.formatIso(item.updatedAt);
17051732 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";
22
33import { deriveAssetUrlState } from "./asset-url-state";
44
5const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" };
5const SUCCESS = {
6 _tag: "Success" as const,
7 url: "https://environment.example/api/assets/abc",
8 expiresAt: Number.MAX_SAFE_INTEGER,
9};
610
711describe("deriveAssetUrlState", () => {
812 it("passes a resolved URL through on a live environment", () => {
apps/server/src/attachmentStore.test.ts+25
......@@ -16,6 +16,7 @@ import {
1616 parseThreadSegmentFromAttachmentId,
1717 resolveAttachmentPathById,
1818 sweepStalePendingAttachments,
19 threadHtmlRenderAttachmentIds,
1920} from "./attachmentStore.ts";
2021
2122describe("attachmentStore", () => {
......@@ -219,3 +220,27 @@ describe("attachmentStore", () => {
219220 }
220221 });
221222});
223
224describe("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";
44import * as NodePath from "node:path";
55
66import type { ChatAttachment } from "@t3tools/contracts";
7import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
78
89import {
910 normalizeAttachmentRelativePath,
......@@ -114,6 +115,25 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
114115 return match[1]?.toLowerCase() ?? null;
115116}
116117
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 */
122export 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
117137/** Null for attachment types this build does not know; callers skip those. */
118138export function attachmentRelativePath(attachment: ChatAttachment): string | null {
119139 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.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
5import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
6import {
7 HTML_RENDER_MEASURE_FONTS,
8 HTML_RENDER_MEASURE_WIDTHS,
9 htmlRenderTheme,
10} from "@t3tools/shared/htmlRender";
11import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Base64 from "effect/encoding/Base64";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as NodeDgram from "node:dgram";
21import * as NodeHttp from "node:http";
22import * as NodeURL from "node:url";
23
24import { resolveAttachmentPathById } from "../attachmentStore.ts";
25import * as ServerConfig from "../config.ts";
26import * as HtmlRender from "./HtmlRender.ts";
27import * 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.
31const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL";
32
33const 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 );
55const testLayer = htmlRenderLayer();
56
57const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
58
59describe("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 @@
1import type { ThreadId } from "@t3tools/contracts";
2import { HostProcessUserId } from "@t3tools/shared/hostProcess";
3import {
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";
14import {
15 T3_CODE_DARK_THEME_COLORS,
16 T3_CODE_LIGHT_THEME_COLORS,
17 type ThemeAppearance,
18} from "@t3tools/shared/themePalettes";
19import * as Context from "effect/Context";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import type * as Path from "effect/Path";
25import * as Schema from "effect/Schema";
26import * as Semaphore from "effect/Semaphore";
27import * as Stream from "effect/Stream";
28import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
29
30import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
31import { createAttachmentId } from "../attachmentStore.ts";
32import * as ServerConfig from "../config.ts";
33import * as HeadlessChrome from "./headlessChrome.ts";
34import * as PreviewBrowser from "./PreviewBrowser.ts";
35
36const MIB = 1024 * 1024;
37const MAX_IMAGE_BYTES = 10 * MIB;
38const MAX_PAGE_BYTES = 25 * MIB;
39const formatMib = (bytes: number) => `${(bytes / MIB).toFixed(1)} MiB`;
40
41export 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
50export 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
59export 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
68export 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
77export type HtmlRenderPrepareError =
78 | HtmlRenderImagesNotFoundError
79 | HtmlRenderImageTooLargeError
80 | HtmlRenderPageTooLargeError;
81
82export 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
94export 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
126const 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};
137const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|");
138// POSIX `/…` (not protocol-relative `//…`) or Windows `C:\…` / `C:/…`.
139const 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.
142const 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
148const 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.
155const filePathFor = (reference: string) =>
156 /^[a-z]:/i.test(reference) ? reference.replaceAll("\\\\", "\\") : reference;
157
158const dataUriPrefix = (path: string) =>
159 `data:${IMAGE_MIME_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"};base64,`;
160
161const 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 */
168const 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. */
185const 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 */
195const 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. */
209const 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. */
228const 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
319const MIN_PREVIEW_WIDTH = 240;
320const MAX_PREVIEW_WIDTH = 1_600;
321// Each preview or measurement runs its own browser; more at once mostly costs memory.
322const MAX_CONCURRENT_BROWSERS = 2;
323// Publishing waits this long at most for heights before returning without them.
324const MEASURE_TIMEOUT = "6 seconds";
325
326// Measured in the dark theme with Arial-metric fonts, so text wraps close to how clients show it.
327const MEASURE_FRAGMENT = htmlRenderThemeFragment(
328 htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_MEASURE_FONTS),
329);
330
331const 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
493export const layer = Layer.effect(HtmlRender, make);
apps/server/src/htmlRender/PreviewBrowser.test.ts created+296
......@@ -0,0 +1,296 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import type * as Duration from "effect/Duration";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import { HttpClient, HttpClientResponse } from "effect/http";
15import * as NodeCrypto from "node:crypto";
16import * as NodeZlib from "node:zlib";
17
18import * as PreviewBrowser from "./PreviewBrowser.ts";
19
20// POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite.
21const hostPlatform: NodeJS.Platform =
22 HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux";
23const executableName =
24 hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
25const ROOT = "chrome-headless-shell-fixture/";
26
27/** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */
28const 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
69const 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
77const 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
123it.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
255it("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
286it("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.
2import * as EffectNodeStream from "@effect/platform-node/NodeStream";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Cause from "effect/Cause";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Deferred from "effect/Deferred";
8import type * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Stream from "effect/Stream";
18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19import * as NodeCrypto from "node:crypto";
20
21import * as ServerConfig from "../config.ts";
22import { 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.
30const VERSION = "154.0.8037.92";
31const 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
58const 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
72export 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
81export 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
96const megabytes = (bytes: number) => Math.round(bytes / 1_000_000);
97
98export 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}
106const isInstallError = Schema.is(PreviewBrowserInstallError);
107
108export 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
120export 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
129export 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
146export 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
153const DOWNLOAD_TIMEOUT = "15 minutes";
154// Longer than any install can run: the download times out at 15 minutes.
155const STAGING_STALE_AFTER_MS = 60 * 60_000;
156
157const wrapFailure = (detail: string) => (cause: unknown) =>
158 isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause });
159
160type 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
169export 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
456export 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 @@
1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Queue from "effect/Queue";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import { publicProxy } from "./publicProxy.ts";
14
15export 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. */
25export 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
34type BrowserFailure = HtmlRenderBrowserError | HtmlRenderSandboxUnavailableError;
35
36export interface ConsoleMessage {
37 readonly level: "log" | "info" | "warning" | "error";
38 readonly text: string;
39}
40
41const MAX_CONSOLE_MESSAGES = 20;
42const MAX_CONSOLE_TEXT_CHARS = 500;
43const VIEWPORT_HEIGHT = 800;
44const MAX_CAPTURE_HEIGHT = 4_000;
45const 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.
55const PAGE_ORIGIN = "http://t3-page.localhost";
56const PAGE_URL = `${PAGE_ORIGIN}/page.html`;
57// Stack traces and load errors name the page this way instead of its URL.
58const PAGE_NAME = "page.html";
59// Each measuring load reads its own copy of the page off the pipe.
60const MEASURE_CONCURRENCY = 3;
61
62const 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 */
68const 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.
72const NO_SANDBOX_SIGNATURE = "No usable sandbox";
73
74const 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);
84const decodeCdpMessage = Schema.decodeUnknownOption(CdpMessage);
85const encodeCdpCommand = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
86const RemoteObject = Schema.Struct({
87 type: Schema.String,
88 value: Schema.optional(Schema.Unknown),
89 description: Schema.optional(Schema.String),
90});
91const decodeConsoleApiCalled = Schema.decodeUnknownOption(
92 Schema.Struct({ type: Schema.String, args: Schema.Array(RemoteObject) }),
93);
94const decodeExceptionThrown = Schema.decodeUnknownOption(
95 Schema.Struct({
96 exceptionDetails: Schema.Struct({
97 text: Schema.String,
98 exception: Schema.optional(RemoteObject),
99 }),
100 }),
101);
102const 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);
110const 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
120const 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
125const 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. */
139const 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.
161const 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.
165const MEASURE_EXPRESSION =
166 "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()";
167
168const Ignored = Schema.Unknown;
169const Navigation = Schema.Struct({ errorText: Schema.optional(Schema.String) });
170const Measured = Schema.Struct({ result: Schema.Struct({ value: Schema.Finite }) });
171
172interface 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 */
188const 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. */
531const 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. */
545export 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 */
583export 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.
2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vite-plus/test";
4import * as Effect from "effect/Effect";
5import * as NodeNet from "node:net";
6import * as NodeOS from "node:os";
7
8import { publicProxy } from "./publicProxy.ts";
9
10// A public IPv4 address this machine holds, which no private range covers.
11const OWN_PUBLIC_IPV4 = "198.51.100.7";
12vi.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. */
33const 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
50const 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
58const 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. */
71const 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
76const 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
86describe("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.
2import * as NodeDnsPromises from "node:dns/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5
6import * as Effect from "effect/Effect";
7
8/** This machine and its local networks, which HTML previews must never reach. */
9const LOCAL_ADDRESSES = new NodeNet.BlockList();
10for (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.
25for (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
36const NAT64 = new NodeNet.BlockList();
37NAT64.addSubnet("64:ff9b::", 96, "ipv6");
38const SIX_TO_FOUR = new NodeNet.BlockList();
39SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6");
40
41/** The sixteen-bit groups of an IPv6 address, which may end in dotted IPv4. */
42const 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 */
62const 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. */
72const 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 */
85const 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 */
97const 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.
107const 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.
110const 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 */
116const 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. */
121const 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 */
147export 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", () => {
675675 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
676676 ).toMatchObject({
677677 "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",
679679 });
680680 });
681681 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
682682 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
683683 expect(assetResponseHeaders(path)).toMatchObject({
684684 "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",
686686 });
687687 }
688688 });
apps/server/src/http.ts+3-1
......@@ -56,7 +56,9 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin
5656// HTML previews are agent output, not the app. The sandbox gives the document an
5757// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
5858// out of reach. Relative sibling assets still load through their signed URLs.
59const 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.
61const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups";
6062
6163// Types a browser may render as a document if a proxy strips the disposition
6264// 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";
1111import * as Sink from "effect/Sink";
1212import * as Stream from "effect/Stream";
1313import type * as Types from "effect/Types";
14import { McpProtocol, McpSchema, McpServer, Tool } from "effect/ai";
14import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai";
1515import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
16import { PreviewAutomationError } from "@t3tools/contracts";
16import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
1717
1818import packageJson from "../../package.json" with { type: "json" };
1919import * as ServerConfig from "../config.ts";
2020import * as DeviceService from "../device/DeviceService.ts";
21import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as PreviewBrowser from "../htmlRender/PreviewBrowser.ts";
2123import * as McpInvocationContext from "./McpInvocationContext.ts";
2224import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2325import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
......@@ -58,6 +60,11 @@ import {
5860 DeviceScreenshotToolkit,
5961 DeviceStandardToolkit,
6062} from "./toolkits/device/tools.ts";
63import {
64 HtmlPreviewToolkitHandlersLive,
65 HtmlRenderToolkitHandlersLive,
66} from "./toolkits/html/handlers.ts";
67import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
6168
6269const unauthorized = HttpServerResponse.jsonUnsafe(
6370 {
......@@ -510,12 +517,13 @@ interface ImageToolResult {
510517}
511518
512519/**
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.
515523 */
516524const 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>) => {
519527 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
520528 return Effect.failCause(cause).pipe(Effect.orDie);
521529 }
......@@ -528,6 +536,10 @@ const imageToolFailure =
528536 typeof firstFailure._tag === "string"
529537 ? firstFailure._tag
530538 : `${toolName}Error`;
539 const message =
540 typeof failureText === "string" || failures[0] === undefined
541 ? undefined
542 : failureText(failures[0].error);
531543 const result = new McpSchema.CallToolResult({
532544 isError: true,
533545 structuredContent: {
......@@ -535,9 +547,16 @@ const imageToolFailure =
535547 _tag: errorTag,
536548 operation,
537549 failureCount: failures.length,
550 ...(message === undefined ? {} : { message }),
538551 },
539552 },
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 ],
541560 });
542561 return Effect.logWarning(`${toolName} failed`, {
543562 operation,
......@@ -563,7 +582,7 @@ const registerImageTool = <T extends Tool.Any, E, R>(
563582 McpInvocationContext.McpInvocationContext
564583 >,
565584 operation: string,
566 failureText: string,
585 failureText: string | ((error: E) => string),
567586) =>
568587 Effect.gen(function* () {
569588 const server = yield* McpServer.McpServer;
......@@ -647,6 +666,32 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh
647666 );
648667});
649668
669const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);
670
671const 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
690export 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
650695const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe(
651696 Layer.provide(PreviewStandardToolkitHandlersLive),
652697);
......@@ -726,4 +771,5 @@ export const layer = Layer.mergeAll(
726771 WorktreeToolkitRegistrationLive,
727772 PullRequestsToolkitRegistrationLive,
728773 DeviceToolkitRegistrationLive,
774 HtmlToolkitRegistrationLive,
729775).pipe(Layer.provideMerge(McpTransportLive));
apps/server/src/mcp/toolkits/core.test.ts+54
......@@ -1,18 +1,23 @@
11import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
23import { expect, it } from "@effect/vitest";
34import {
45 DEFAULT_SERVER_SETTINGS,
56 ChatImageAttachment,
67 EnvironmentId,
78 ProviderInstanceId,
9 RunId,
810 ThreadId,
11 type OrchestrationV2ThreadShell,
912} from "@t3tools/contracts";
1013import * as Effect from "effect/Effect";
1114import * as Layer from "effect/Layer";
1215import * as Schema from "effect/Schema";
1316import { McpAttachmentInput } from "./attachment/input.ts";
1417import { McpSchema, McpServer, Tool } from "effect/ai";
18import { FetchHttpClient } from "effect/http";
1519
20import * as ServerConfig from "../../config.ts";
1621import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
1722import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
1823import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
......@@ -40,11 +45,13 @@ const declaredFailure = (result: McpSchema.CallToolResult) => {
4045 return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined;
4146};
4247import { PullRequestsToolkit } from "./pullRequests/tools.ts";
48import { HtmlToolkit } from "./html/tools.ts";
4349import {
4450 resolveT3McpToolDefinition,
4551 resolveT3McpToolPresentation,
4652 resolveT3McpToolSummaryAction,
4753} from "@t3tools/shared/t3McpToolPresentation";
54import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
4855
4956const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);
5057
......@@ -61,6 +68,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
6168 PreviewControlsToolkit,
6269 DeviceToolkit,
6370 PullRequestsToolkit,
71 HtmlToolkit,
6472 ]) {
6573 for (const tool of Object.values(toolkit.tools)) {
6674 expect(names.has(tool.name)).toBe(false);
......@@ -172,6 +180,52 @@ it.effect("returns a bounded public failure without serializing storage causes",
172180 ),
173181);
174182
183it.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
175229it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
176230 const settings = {
177231 ...DEFAULT_SERVER_SETTINGS,
apps/server/src/mcp/toolkits/html/handlers.ts created+67
......@@ -0,0 +1,67 @@
1import { OrchestratorMcpFailure } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3
4import * as HtmlRender from "../../../htmlRender/HtmlRender.ts";
5import * as McpInvocationContext from "../../McpInvocationContext.ts";
6import { readMutationCaller } from "../../threadAccess.ts";
7import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts";
8
9const 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.
16const 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
22const 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
61export const HtmlPreviewToolkitHandlersLive = HtmlPreviewToolkit.toLayer({
62 html_preview: handlers.html_preview,
63});
64
65export 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 @@
1import { OrchestratorMcpFailure } from "@t3tools/contracts";
2import {
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";
11import * as Schema from "effect/Schema";
12import { Tool, Toolkit } from "effect/ai";
13
14import * as HtmlRender from "../../../htmlRender/HtmlRender.ts";
15import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
16import * as McpInvocationContext from "../../McpInvocationContext.ts";
17
18const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_000)).annotate({
19 description: "A complete, self-contained HTML document.",
20});
21
22const 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
25export 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.
70const 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
105export const HtmlPreviewToolkit = Toolkit.make(HtmlPreviewTool);
106
107export const HtmlRenderToolkit = Toolkit.make(HtmlRenderTool);
108
109export 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";
5252import * as ServerConfig from "../../config.ts";
5353import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
5454import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
55import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts";
5556import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
5657import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
5758import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
......@@ -655,6 +656,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
655656 ...Object.values(ProjectToolkit.tools),
656657 ...Object.values(EnvironmentToolkit.tools),
657658 ...Object.values(PreviewControlsToolkit.tools),
659 ...Object.values(HtmlToolkit.tools),
658660 ]
659661 .filter((tool) => Context.get(tool.annotations, Tool.Readonly))
660662 .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> = [
946946 "mcp__t3-code__t3_environment_read",
947947 "mcp__t3-code__t3_queue_list",
948948 "mcp__t3-code__t3_queue_read",
949 "mcp__t3-code__html_preview",
950 "mcp__t3-code__html_render",
949951];
950952
951953// 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 {
108108 if (part?.type === "text" && typeof part.text === "string") texts.push(part.text);
109109 }
110110 }
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.
111113 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);
113116 }
114117 if (texts.length > 0) return texts.join("\\n");
115118 return JSON.stringify(result);
apps/server/src/orchestration-v2/ProjectionStore.ts+40-11
......@@ -70,6 +70,7 @@ import * as Schema from "effect/Schema";
7070import * as SqlClient from "effect/sql/SqlClient";
7171import type * as Statement from "effect/sql/Statement";
7272
73import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";
7374import {
7475 isThreadHistoryUserTurn,
7576 isThreadHistoryTurnStart,
......@@ -4556,13 +4557,37 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
45564557 );
45574558
45584559 const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) =>
4559 sql<{ id: string }>`
4560 Effect.all([
4561 sql<{ id: string }>`
45604562 SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
45614563 FROM orchestration_v2_projection_messages AS message,
45624564 json_each(message.payload_json, '$.attachments') AS attachment
45634565 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 ]),
45664591 Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })),
45674592 );
45684593
......@@ -5871,17 +5896,21 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
58715896 ),
58725897 ),
58735898 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] : [],
58815909 ),
58825910 ),
58835911 ]),
5884 ),
5912 ]),
5913 ),
58855914 getThreadRecords: (threadId, fields, filter) =>
58865915 Effect.gen(function* () {
58875916 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";
2727import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2828import * as NodeCrypto from "node:crypto";
2929import * as NodeFSP from "node:fs/promises";
30import type * as NodeStream from "node:stream";
31import * as Yauzl from "yauzl";
3230
3331import * as ServerConfig from "../config.ts";
32import { openZipArchive } from "../zipArchive.ts";
3433import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts";
3534import {
3635 buildAntigravityAcpSpawnInput,
......@@ -148,121 +147,6 @@ function isRunning(state: ProviderInstallState) {
148147 );
149148}
150149
151/** Open only a verified local archive. Entries stay lazy and extraction stays bounded. */
152const 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
266150export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* (
267151 options: AntigravityInstallationOptions,
268152) {
......@@ -665,7 +549,9 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
665549
666550 yield* report("extracting", "Extracting the verified runtime.");
667551 yield* Effect.gen(function* () {
668 const archive = yield* openArchive(archivePath);
552 const archive = yield* openZipArchive(archivePath, (detail, cause) =>
553 installationError("extract", detail, cause),
554 );
669555 if (archive.entryCount !== 2) {
670556 return yield* installationError(
671557 "extract",
apps/server/src/provider/T3OrchestrationInstructions.ts+4
......@@ -28,6 +28,10 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO
2828Tool 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.
2929
3030ACP 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
34When 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.
3135`;
3236
3337export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
apps/server/src/zipArchive.ts created+115
......@@ -0,0 +1,115 @@
1import * as Effect from "effect/Effect";
2import type * as NodeStream from "node:stream";
3import * 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 */
10export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import {
3 HTML_RENDER_COLUMN_WIDTH,
4 htmlRenderFileName,
5 htmlRenderFrameHeight,
6 type HtmlRenderReference,
7} from "@t3tools/shared/htmlRender";
8import { Maximize2Icon } from "lucide-react";
9import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
11import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
12import type { ChatFileAttachment } from "~/types";
13
14import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
15import { Button } from "../ui/button";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17
18// A frame may load its URL a little after mounting.
19const 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 */
26export 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 {
4848 type T3McpToolPresentation,
4949} from "@t3tools/shared/t3McpToolPresentation";
5050import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
51import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender";
5152import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
5253import { formatWorkspaceRelativePath } from "../../filePathDisplay";
5354import {
......@@ -64,6 +65,7 @@ function timelineEntryRunId(entry: TimelineEntry): RunId | null {
6465 if (entry.kind === "proposed-plan") {
6566 return entry.proposedPlan.runId;
6667 }
68 if (entry.kind === "html-render") return entry.runId;
6769 return entry.kind === "work" ? (entry.entry.runId ?? null) : null;
6870}
6971
......@@ -587,6 +589,12 @@ type MessagesTimelineRowContent =
587589 id: string;
588590 createdAt: string;
589591 proposedPlan: ProposedPlan;
592 }
593 | {
594 kind: "html-render";
595 id: string;
596 createdAt: string;
597 htmlRender: HtmlRenderReference;
590598 };
591599
592600export interface StableMessagesTimelineRowsState {
......@@ -708,6 +716,8 @@ function deriveSupersededAttemptFolds(
708716 entry.attempt?.status !== "superseded" ||
709717 unfoldedRunIds.has(entry.attempt.runId) ||
710718 (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" ||
711721 timelineEntryIsPersistentResourceCard(entry) ||
712722 (entry.kind === "work" && entry.entry.itemType === "system_notice")
713723 ) {
......@@ -1606,6 +1616,16 @@ export function deriveMessagesTimelineRows(input: {
16061616 continue;
16071617 }
16081618
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
16091629 if (timelineEntry.kind === "event") {
16101630 const previous = nextRows.at(-1);
16111631 if (
......@@ -2016,6 +2036,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
20162036 case "proposed-plan":
20172037 return a.proposedPlan === (b as typeof a).proposedPlan;
20182038
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
20192045 case "event":
20202046 return (
20212047 a.projectedItem === (b as typeof a).projectedItem &&
apps/web/src/components/chat/MessagesTimeline.tsx+20-1
......@@ -168,6 +168,7 @@ import {
168168 SnapShotAttachmentDetails,
169169} from "./SnapShotAttachmentDetails";
170170import { ProposedPlanCard } from "./ProposedPlanCard";
171import { HtmlRenderFrame } from "./HtmlRenderFrame";
171172import { ChangedFilesCard } from "./ChangedFilesTree";
172173import { useFileContextMenuHandler } from "../../fileContextMenu";
173174import { useProject, useThreadShell } from "../../state/entities";
......@@ -1779,7 +1780,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
17791780 !row.showAssistantMeta) ||
17801781 row.kind === "worktree-setup" ||
17811782 row.kind === "event" ||
1782 row.kind === "attempt-fold"
1783 row.kind === "attempt-fold" ||
1784 row.kind === "html-render"
17831785 ? "pb-2"
17841786 : "pb-4",
17851787 (row.kind === "message" && row.message.role === "assistant") ||
......@@ -1827,6 +1829,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
18271829 ) : null}
18281830 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
18291831 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1832 {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null}
18301833 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
18311834 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
18321835 {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
......@@ -2700,6 +2703,22 @@ function ProposedPlanTimelineRow({
27002703 );
27012704}
27022705
2706function 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
27032722type V2EventTone = "muted" | "warning" | "danger" | "success";
27042723
27052724function v2EventPresentation(item: OrchestrationV2TurnItem): {
apps/web/src/components/files/AttachmentFilePreview.tsx+16-5
......@@ -55,9 +55,12 @@ function renderedToggleLabel(mode: "markdown" | "html" | "table", rendered: bool
5555export function AttachmentFilePreview(props: {
5656 name: string;
5757 mimeType: string;
58 /** Zero when unknown. */
5859 sizeBytes: number;
5960 file?: Blob | null;
6061 asset?: { environmentId: EnvironmentId; attachmentId: string };
62 /** An agent's HTML render, shown in the app theme. */
63 htmlRender?: boolean;
6164 /** First crumb: where the file comes from. */
6265 origin?: string;
6366 onRemove?: () => void;
......@@ -242,7 +245,12 @@ export function AttachmentFilePreview(props: {
242245 <ReadOnlySourcePreview name={props.name} text={content.text} />
243246 )
244247 ) : 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 />
246254 ) : kind === "audio" ? (
247255 <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} />
248256 ) : kind === "video" ? (
......@@ -286,9 +294,11 @@ export function AttachmentFilePreview(props: {
286294 <span aria-current="page" className="min-w-0 truncate px-0.5 font-medium text-foreground">
287295 {props.name}
288296 </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}
292302 </div>
293303 {renderedMode ? (
294304 <FileSurfaceAction
......@@ -341,7 +351,8 @@ export function AttachmentFilePreview(props: {
341351 </div>
342352 {content?.truncated ? (
343353 <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
345356 the file to read it in full.
346357 </FileSurfaceNotice>
347358 ) : null}
apps/web/src/components/files/BrowserDocumentFrame.tsx+86-1
......@@ -1,3 +1,14 @@
1import {
2 htmlRenderThemeFragment,
3 htmlRenderThemeMessage,
4 htmlRenderResult,
5 readHtmlRenderLinkRequest,
6} from "@t3tools/shared/htmlRender";
7import { useEffect, useRef, useState } from "react";
8
9import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
10import { cn } from "~/lib/utils";
11
112/**
213 * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small
314 * 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 =>
1324 * Renders an HTML or PDF document from its URL. HTML runs in a sandboxed frame
1425 * with an opaque origin, so a page cannot reach the app's session or storage.
1526 * 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.
1628 */
1729export function BrowserDocumentFrame(props: {
1830 readonly src: string;
1931 readonly title: string;
2032 readonly pdf: boolean;
33 readonly htmlRender?: boolean;
2134}) {
2235 const className = "min-h-0 flex-1 border-0 bg-white";
2336 return props.pdf ? (
......@@ -28,13 +41,85 @@ export function BrowserDocumentFrame(props: {
2841 title={props.title}
2942 className={className}
3043 />
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 />
3151 ) : (
3252 <iframe
3353 key={props.src}
3454 src={props.src}
3555 title={props.title}
3656 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 */
68export 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}
38123 />
39124 );
40125}
apps/web/src/components/files/FilePreviewPanel.tsx+2-1
......@@ -1,6 +1,5 @@
11import { Spinner } from "~/components/ui/spinner";
22import type {
3 ChatFileAttachment,
43 EditorId,
54 EnvironmentId,
65 ResolvedKeybindingsConfig,
......@@ -40,6 +39,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh
4039import { resolveDiffThemeName } from "~/lib/diffRendering";
4140import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
4241import { cn } from "~/lib/utils";
42import type { ChatFileAttachment } from "~/types";
4343import { isPreviewSupportedInRuntime } from "~/previewStateStore";
4444import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
4545import { ScrollArea } from "~/components/ui/scroll-area";
......@@ -1193,6 +1193,7 @@ export default function FilePreviewPanel({
11931193 mimeType={attachment.mimeType}
11941194 sizeBytes={attachment.sizeBytes}
11951195 asset={{ environmentId, attachmentId: attachment.id }}
1196 htmlRender={attachment.htmlRender === true}
11961197 />
11971198 ) : relativePath && isVideo && absolutePath ? (
11981199 <WorkspaceVideoPreview
apps/web/src/hooks/useHtmlRenderTheme.ts created+49
......@@ -0,0 +1,49 @@
1import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender";
2import { useMemo, useSyncExternalStore } from "react";
3
4import { appearanceFontStack } from "../appearanceFonts";
5import {
6 getStandardThemeColors,
7 getThemeColorsForMode,
8 getThemeDefinition,
9 resolveThemeHalf,
10 subscribeToCustomThemes,
11 type ThemeAppearance,
12 type ThemeHalves,
13 type ThemePreference,
14} from "../themePalette";
15import { useClientSettings } from "./useSettings";
16import { useTheme } from "./useTheme";
17
18/** The palette `applyTheme` paints for a preference, or the stock look when no theme applies. */
19function 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. */
31export 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 @@
88 * workspace paths, and diff/files remain singleton surfaces.
99 */
1010import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
11import {
12 EnvironmentId,
13 ThreadId,
14 type ChatFileAttachment,
15 type ScopedThreadRef,
16} from "@t3tools/contracts";
11import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
1712import { create } from "zustand";
1813import { createJSONStorage, persist } from "zustand/middleware";
1914
2015import { resolveStorage } from "./lib/storage";
2116import type { ThreadPanelPresentation } from "./rightPanelLayout";
17import type { ChatFileAttachment } from "./types";
2218
2319const RIGHT_PANEL_KINDS = [
2420 "diff",
apps/web/src/session-logic.test.ts+141
......@@ -1211,6 +1211,147 @@ describe("native provider presentation in the v2 timeline", () => {
12111211 });
12121212});
12131213
1214describe("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
12141355describe("work-log failure policy (#7999/#7893)", () => {
12151356 const toolEntry = (overrides: Record<string, unknown>) =>
12161357 ({
apps/web/src/session-logic.ts+26
......@@ -20,6 +20,8 @@ import {
2020 formatReadToolLabel,
2121 formatSearchToolLabel,
2222} from "@t3tools/shared/toolActivity";
23import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
24import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
2325import {
2426 contextCompactionLabel,
2527 workEntryIndicatesToolFailure,
......@@ -130,6 +132,14 @@ export type TimelineEntry = (
130132 readonly createdAt: string;
131133 readonly proposedPlan: ProposedPlan;
132134 }
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 }
133143 | {
134144 readonly id: string;
135145 readonly kind: "work";
......@@ -685,6 +695,22 @@ export function deriveTimelineEntriesFromVisibleTurnItems(
685695 continue;
686696 }
687697
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
688714 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
689715 entries.push({
690716 id: item.id,
apps/web/src/types.ts+2
......@@ -49,6 +49,8 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {
4949export interface ChatFileAttachment extends ContractChatFileAttachment {
5050 readonly previewUrl?: string;
5151 readonly downloadable?: boolean;
52 /** A page an agent published with `html_render`; its viewer hands it the app theme. */
53 readonly htmlRender?: boolean;
5254}
5355
5456// Attachment types this build does not know pass through with the contract
docs/README.md+1
......@@ -12,6 +12,7 @@
1212- [Appearance and themes](./user/appearance.md)
1313- [Keyboard shortcuts](./user/keybindings.md)
1414- [SnapShots](./user/snap-shot.md)
15- [Visual replies](./user/html-renders.md)
1516- [Import browser sessions](./user/browser-import.md)
1617- [Devices](./user/devices.md)
1718- [Usage and limits](./user/usage.md)
docs/user/html-renders.md created+9
......@@ -0,0 +1,9 @@
1# Visual replies
2
3Agents 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
5Pages 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
7Agents 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
9Before 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 =
7373 | {
7474 readonly _tag: "Success";
7575 readonly url: string;
76 /** When the signed URL stops working, in epoch milliseconds. */
77 readonly expiresAt: number;
7678 /** The host path the server chose to serve, when it differs from what was asked for. */
7779 readonly sourcePath?: string;
7880 /** Pixel size from the image header, when the server could read one. */
......@@ -90,6 +92,7 @@ export function assetUrlStateFromResult(
9092 return {
9193 _tag: "Success",
9294 url,
95 expiresAt: result.value.expiresAt,
9396 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
9497 ...(result.value.imageDimensions !== undefined
9598 ? { imageDimensions: result.value.imageDimensions }
packages/client-runtime/src/t3ToolSummary.ts+6
......@@ -395,6 +395,12 @@ export function summarizeT3ToolCalls(
395395 case "device":
396396 label = phrase("Used", "use", `device controls ${times}`);
397397 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;
398404 case "capabilities":
399405 label = phrase("Checked", "check", `orchestration capabilities ${times}`);
400406 break;
packages/shared/package.json+4
......@@ -115,6 +115,10 @@
115115 "types": "./src/toolOutput.ts",
116116 "import": "./src/toolOutput.ts"
117117 },
118 "./htmlRender": {
119 "types": "./src/htmlRender.ts",
120 "import": "./src/htmlRender.ts"
121 },
118122 "./favicon": {
119123 "types": "./src/favicon.ts",
120124 "import": "./src/favicon.ts"
packages/shared/src/htmlRender.test.ts created+209
......@@ -0,0 +1,209 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 htmlRenderFrameHeight,
5 htmlRenderReferencesEqual,
6 htmlRenderTheme,
7 htmlRenderThemeFragment,
8 injectHtmlRenderBootstrap,
9 htmlRenderThemeMessage,
10 readHtmlRenderLinkRequest,
11 readHtmlRenderReference,
12} from "./htmlRender.ts";
13import { T3_CODE_DARK_THEME_COLORS, T3_CODE_LIGHT_THEME_COLORS } from "./themePalettes.ts";
14import { htmlRenderFromToolItem } from "./toolOutput.ts";
15
16const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 };
17
18describe("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
75describe("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
102describe("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
113describe("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
128describe("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
137describe("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
164describe("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 @@
1import {
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
16export const HTML_RENDER_TOOL_NAME = "html_render";
17export const HTML_RENDER_MIN_HEIGHT = 80;
18export const HTML_RENDER_MAX_HEIGHT = 2000;
19export const HTML_RENDER_MAX_TITLE_LENGTH = 200;
20
21/** What the `html_render` tool result carries so clients can show the page. */
22export 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. */
35export 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 */
41export const HTML_RENDER_COLUMN_WIDTH = 728;
42
43const MAX_MEASURED_HEIGHTS = 24;
44
45function 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
65export 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. */
88export 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 */
109export 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. */
120export 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
130export function clampHtmlRenderHeight(height: number): number {
131 return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height)));
132}
133
134export interface HtmlRenderFonts {
135 readonly sans: string;
136 readonly mono: string;
137}
138
139export 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 */
150export 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. */
156export 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.
163const 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 */
185export 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. */
235export 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. */
247export 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
256const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
257const OPEN_LINK_METHOD = "ui/open-link";
258
259/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */
260export 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. */
273export function htmlRenderResult(id: string | number) {
274 return { jsonrpc: "2.0", id, result: {} } as const;
275}
276
277const THEME_FRAGMENT_KEY = "t3-theme";
278
279/** URL fragment that hands a render its theme before first paint. */
280export 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. */
285export 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.
295const 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
299function 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.
313const 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
315function 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.
333const 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 */
363export 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", () => {
8787 }
8888 });
8989
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
9097 it("keeps unknown MCP tools on the generic renderer path", () => {
9198 expect(resolveT3McpToolPresentation("mcp__github__search_issues")).toBeNull();
9299 expect(resolveT3McpToolPresentation("t3-code.not_a_real_tool")).toBeNull();
packages/shared/src/t3McpToolPresentation.ts+21-2
......@@ -58,7 +58,9 @@ export type T3McpToolSummaryAction =
5858 | "watch-pr"
5959 | "unwatch-pr"
6060 | "browser"
61 | "device";
61 | "device"
62 | "html-preview"
63 | "html-render";
6264
6365export interface T3McpToolDefinition {
6466 readonly displayName: string;
......@@ -284,6 +286,8 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
284286 "attachment-discard",
285287 ),
286288 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"),
287291};
288292
289293/**
......@@ -324,7 +328,22 @@ function resolveT3McpToolName(value: string): string | null {
324328
325329 const prefixed = /^(?:mcp[-_]{1,2})?t3[-_ ]?code(?:__|[-_.:/ ])(?<tool>.+)$/i.exec(label);
326330 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. */
344export 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;
328347}
329348
330349export function resolveT3McpToolDefinition(
packages/shared/src/toolOutput.ts+20
......@@ -1,6 +1,13 @@
11import { isProviderSendTurnSupportedImageMimeType } from "@t3tools/contracts";
22import * as Predicate from "effect/Predicate";
33
4import {
5 HTML_RENDER_TOOL_NAME,
6 readHtmlRenderReference,
7 type HtmlRenderReference,
8} from "./htmlRender.ts";
9import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
10
411const MAX_PARSED_BYTES = 16_384;
512const MAX_METADATA_BYTES = 8_192;
613const MAX_ID_LENGTH = 256;
......@@ -28,6 +35,7 @@ interface CompactToolOutput {
2835 taskId?: string;
2936 scheduledTaskId?: string;
3037 status?: "rolled_back";
38 htmlRender?: HtmlRenderReference;
3139 thread?: { threadId: string };
3240 threads?: Array<{ threadId?: string; status?: "rolled_back" }>;
3341}
......@@ -111,6 +119,8 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un
111119 if (id !== undefined) output[key] = id;
112120 }
113121 if (data.status === "rolled_back") output.status = "rolled_back";
122 const htmlRender = readHtmlRenderReference(data.htmlRender);
123 if (htmlRender !== undefined) output.htmlRender = htmlRender;
114124 const nestedThreadId = Predicate.isObject(data.thread)
115125 ? boundedId(data.thread.threadId)
116126 : undefined;
......@@ -154,6 +164,16 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un
154164 return Object.keys(output).length === 0 ? undefined : output;
155165}
156166
167/** The page a completed `html_render` tool call published, if this item is one. */
168export 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
157177/** Some providers report completion even when command output describes a failure. */
158178export function toolOutputIndicatesFailure(text: string): boolean {
159179 return (