1import type { ThreadId } from "@t3tools/contracts";
2import * as HostProcess 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 { resolveRootCliCommand } from "../cli/invocation.ts";
33import * as ServerConfig from "../config.ts";
34import * as HeadlessChrome from "./headlessChrome.ts";
35import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
36import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
37
38const MIB = 1024 * 1024;
39const MAX_IMAGE_BYTES = 10 * MIB;
40const MAX_PAGE_BYTES = 25 * MIB;
41const formatMib = (bytes: number) => `${(bytes / MIB).toFixed(1)} MiB`;
42
43export class HtmlRenderImagesNotFoundError extends Schema.TaggedError<HtmlRenderImagesNotFoundError>()(
44 "HtmlRenderImagesNotFoundError",
45 { paths: Schema.Array(Schema.String) },
46) {
47 override get message(): string {
48 return `These local images could not be read: ${this.paths.join(", ")}. Use absolute paths to existing image files, or remove them.`;
49 }
50}
51
52export class HtmlRenderImageTooLargeError extends Schema.TaggedError<HtmlRenderImageTooLargeError>()(
53 "HtmlRenderImageTooLargeError",
54 { path: Schema.String, sizeBytes: Schema.Number },
55) {
56 override get message(): string {
57 return `${this.path} is ${formatMib(this.sizeBytes)}; each local image must be at most ${formatMib(MAX_IMAGE_BYTES)}.`;
58 }
59}
60
61export class HtmlRenderPageTooLargeError extends Schema.TaggedError<HtmlRenderPageTooLargeError>()(
62 "HtmlRenderPageTooLargeError",
63 { sizeBytes: Schema.Number },
64) {
65 override get message(): string {
66 return `With its images inlined the page is ${formatMib(this.sizeBytes)}; the limit is ${formatMib(MAX_PAGE_BYTES)}. Use smaller images.`;
67 }
68}
69
70export class HtmlRenderStoreError extends Schema.TaggedError<HtmlRenderStoreError>()(
71 "HtmlRenderStoreError",
72 { cause: Schema.Defect() },
73) {
74 override get message(): string {
75 return "The HTML render could not be saved.";
76 }
77}
78
79export type HtmlRenderPrepareError =
80 | HtmlRenderImagesNotFoundError
81 | HtmlRenderImageTooLargeError
82 | HtmlRenderPageTooLargeError;
83
84export interface HtmlPreview {
85 /** Base64 PNG of the top `capturedHeight` pixels. */
86 readonly png: string;
87 readonly width: number;
88 /** Height the page needs to show without scrolling. */
89 readonly contentHeight: number;
90 readonly capturedHeight: number;
91 readonly consoleMessages: ReadonlyArray<HeadlessChrome.ConsoleMessage>;
92 /** Local image paths that could not be read; they show as broken images. */
93 readonly missingImages?: ReadonlyArray<string>;
94}
95
96export class HtmlRender extends Context.Service<
97 HtmlRender,
98 {
99 /** The self-contained page that gets stored: local images inlined, theme bootstrap injected. */
100 readonly prepare: (html: string) => Effect.Effect<string, HtmlRenderPrepareError>;
101 /**
102 * Stores a prepared page as a thread attachment for clients to show inline,
103 * measuring its height at each client width when the preview browser is
104 * already installed.
105 */
106 readonly publish: (input: {
107 readonly threadId: ThreadId;
108 readonly html: string;
109 readonly title: string;
110 readonly height: number;
111 }) => Effect.Effect<HtmlRenderReference, HtmlRenderPrepareError | HtmlRenderStoreError>;
112 /** Screenshots a page in headless Chrome, tolerating unreadable local images. */
113 readonly preview: (input: {
114 readonly html: string;
115 readonly width?: number | undefined;
116 readonly appearance?: ThemeAppearance | undefined;
117 }) => Effect.Effect<
118 HtmlPreview,
119 | Exclude<HtmlRenderPrepareError, HtmlRenderImagesNotFoundError>
120 | PreviewBrowser.PreviewBrowserInstallError
121 | PreviewBrowser.PreviewBrowserInstallingError
122 | PreviewBrowser.PreviewBrowserUnsupportedError
123 | PreviewBrowserHost.PreviewBrowserHostError
124 | HeadlessChrome.HtmlRenderBrowserError
125 >;
126 }
127>()("t3/htmlRender/HtmlRender") {}
128
129const IMAGE_MIME_TYPES: Record<string, string> = {
130 png: "image/png",
131 jpg: "image/jpeg",
132 jpeg: "image/jpeg",
133 gif: "image/gif",
134 webp: "image/webp",
135 avif: "image/avif",
136 svg: "image/svg+xml",
137 bmp: "image/bmp",
138 ico: "image/x-icon",
139};
140const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|");
141// POSIX `/…` (not protocol-relative `//…`) or Windows `C:\…` / `C:/…`.
142const ABSOLUTE_PATH = String.raw`(?:/(?!/)|[a-z]:[\\/])`;
143// An absolute image path that is a whole quoted string ("…", '…', `…`) or an
144// unquoted CSS url(…). URLs, data:, blob:, and relative paths never match.
145const LOCAL_IMAGE_PATTERN = new RegExp(
146 String.raw`(["'\x60])(${ABSOLUTE_PATH}(?:(?!\1)[^\r\n]){0,2048}?\.(?:${IMAGE_EXTENSIONS}))\1` +
147 String.raw`|url\(\s*(${ABSOLUTE_PATH}[^\s"'\x60()]{0,2048}?\.(?:${IMAGE_EXTENSIONS}))\s*\)`,
148 "gid",
149);
150
151const findLocalImages = (html: string) =>
152 Array.from(html.matchAll(LOCAL_IMAGE_PATTERN)).flatMap((match) => {
153 const span = match.indices?.[2] ?? match.indices?.[3];
154 return span ? [{ start: span[0], end: span[1], path: html.slice(span[0], span[1]) }] : [];
155 });
156
157// Inside a JS string literal a Windows path's backslashes are escaped.
158const filePathFor = (reference: string) =>
159 /^[a-z]:/i.test(reference) ? reference.replaceAll("\\\\", "\\") : reference;
160
161const dataUriPrefix = (path: string) =>
162 `data:${IMAGE_MIME_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"};base64,`;
163
164const latin1 = (bytes: Uint8Array, start: number, end: number) =>
165 String.fromCharCode(...bytes.subarray(start, end));
166
167/**
168 * Whether file bytes are an image, whatever the file is named, so a symlink or
169 * renamed file cannot carry other data, such as a secret, into a page.
170 */
171const isImageBytes = (bytes: Uint8Array) => {
172 const head = latin1(bytes, 0, 12);
173 if (
174 head.startsWith("\x89PNG") ||
175 head.startsWith("\xff\xd8\xff") ||
176 head.startsWith("GIF8") ||
177 head.startsWith("\0\0\x01\0") ||
178 (head.startsWith("BM") && head.slice(6, 10) === "\0\0\0\0") ||
179 (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") ||
180 /^ftyp(?:avif|avis|mif1)$/.test(head.slice(4, 12))
181 ) {
182 return true;
183 }
184 return hasSvgRoot(new TextDecoder().decode(bytes.subarray(0, 4096)));
185};
186
187/** The index just past `token` at or after `from`, or -1 when it never appears. */
188const after = (text: string, token: string, from: number) => {
189 const at = text.indexOf(token, from);
190 return at === -1 ? -1 : at + token.length;
191};
192
193/**
194 * Whether an XML document's root element is <svg>, after any processing
195 * instructions, comments, and a doctype. One forward pass, so no input can
196 * make it slow, and quoted text never counts as markup.
197 */
198const hasSvgRoot = (text: string) => {
199 let at = 0;
200 while (at !== -1) {
201 while (/\s/.test(text.charAt(at))) at += 1;
202 if (text.startsWith("<?", at)) at = after(text, "?>", at + 2);
203 else if (text.startsWith("<!--", at)) at = after(text, "-->", at + 4);
204 else if (text.slice(at, at + 9).toLowerCase() === "<!doctype") at = afterDoctype(text, at + 9);
205 // XML names are case-sensitive, and only these characters can end one here.
206 else return /^<svg[ \t\r\n/>]/.test(text.slice(at, at + 5));
207 }
208 return false;
209};
210
211/** The index just past a doctype whose body starts at `from`, honoring quotes and its internal subset. */
212const afterDoctype = (text: string, from: number) => {
213 let inSubset = false;
214 let at = from;
215 while (at !== -1 && at < text.length) {
216 const char = text[at];
217 if (char === '"' || char === "'") at = after(text, char, at + 1);
218 else if (inSubset && text.startsWith("<!--", at)) at = after(text, "-->", at + 4);
219 else if (inSubset && text.startsWith("<?", at)) at = after(text, "?>", at + 2);
220 else if (char === ">" && !inSubset) return at + 1;
221 else {
222 if (char === "[") inSubset = true;
223 else if (char === "]") inSubset = false;
224 at += 1;
225 }
226 }
227 return -1;
228};
229
230/** Replaces every local image reference with a data URI; unreadable paths stay as written. */
231const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (html: string) {
232 const fileSystem = yield* FileSystem.FileSystem;
233 const references = findLocalImages(html);
234 const files = yield* Effect.forEach(
235 [...new Set(references.map((reference) => reference.path))],
236 (path) =>
237 fileSystem.stat(filePathFor(path)).pipe(
238 Effect.map((info) => ({
239 path,
240 size: info.type === "File" ? Number(info.size) : undefined,
241 })),
242 Effect.orElseSucceed(() => ({ path, size: undefined })),
243 ),
244 { concurrency: 8 },
245 );
246 const oversized = files.find((file) => file.size !== undefined && file.size > MAX_IMAGE_BYTES);
247 if (oversized?.size !== undefined) {
248 return yield* new HtmlRenderImageTooLargeError({
249 path: oversized.path,
250 sizeBytes: oversized.size,
251 });
252 }
253 const sizes = new Map(files.map((file) => [file.path, file.size]));
254 const pageBytes = references.reduce((total, reference) => {
255 const size = sizes.get(reference.path);
256 return size === undefined
257 ? total
258 : total +
259 dataUriPrefix(reference.path).length +
260 Math.ceil(size / 3) * 4 -
261 Buffer.byteLength(reference.path);
262 }, Buffer.byteLength(html));
263 if (pageBytes > MAX_PAGE_BYTES) {
264 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes });
265 }
266 // Files can grow after `stat`. Each read stops one byte past the image
267 // limit, and reading stops once the images read so far cannot fit the page.
268 let readBytes = 0;
269 const images = yield* Effect.forEach(
270 files.filter((file) => file.size !== undefined),
271 (file) =>
272 Effect.gen(function* () {
273 const read = yield* fileSystem
274 .stream(filePathFor(file.path), { bytesToRead: MAX_IMAGE_BYTES + 1 })
275 .pipe(Stream.mkUint8Array, Effect.option);
276 if (Option.isNone(read) || !isImageBytes(read.value)) return [];
277 const bytes = read.value;
278 if (bytes.byteLength > MAX_IMAGE_BYTES) {
279 return yield* new HtmlRenderImageTooLargeError({
280 path: file.path,
281 sizeBytes: bytes.byteLength,
282 });
283 }
284 readBytes += Math.ceil(bytes.byteLength / 3) * 4;
285 if (readBytes > MAX_PAGE_BYTES) {
286 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: readBytes });
287 }
288 return [{ path: file.path, bytes }];
289 }),
290 { concurrency: 4 },
291 ).pipe(Effect.map((entries) => entries.flat()));
292 const dataUris = new Map(
293 images.map(
294 (image) =>
295 // Node's encoder: images run to 10 MiB.
296 [
297 image.path,
298 dataUriPrefix(image.path) + Buffer.from(image.bytes).toString("base64"),
299 ] as const,
300 ),
301 );
302 const parts: Array<string> = [];
303 let cursor = 0;
304 for (const reference of references) {
305 const dataUri = dataUris.get(reference.path);
306 if (dataUri === undefined) continue;
307 parts.push(html.slice(cursor, reference.start), dataUri);
308 cursor = reference.end;
309 }
310 parts.push(html.slice(cursor));
311 const inlined = parts.join("");
312 const inlinedBytes = Buffer.byteLength(inlined);
313 if (inlinedBytes > MAX_PAGE_BYTES) {
314 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: inlinedBytes });
315 }
316 return {
317 html: inlined,
318 missing: files.filter((file) => !dataUris.has(file.path)).map((file) => file.path),
319 };
320});
321
322const MIN_PREVIEW_WIDTH = 240;
323const MAX_PREVIEW_WIDTH = 1_600;
324// Each preview or measurement runs its own browser; more at once mostly costs memory.
325const MAX_CONCURRENT_BROWSERS = 2;
326// Publishing waits this long at most for heights before returning without them.
327const MEASURE_TIMEOUT = "6 seconds";
328
329// Measured in the dark theme with Arial-metric fonts, so text wraps close to how clients show it.
330const MEASURE_FRAGMENT = htmlRenderThemeFragment(
331 htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_MEASURE_FONTS),
332);
333
334const make = Effect.gen(function* () {
335 const fileSystem = yield* FileSystem.FileSystem;
336 const config = yield* ServerConfig.ServerConfig;
337 const previewBrowser = yield* PreviewBrowser.PreviewBrowser;
338 const services = yield* Effect.context<
339 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
340 >();
341 const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);
342 // Chrome's sandbox stays on unless the operator explicitly turns it off.
343 // Chrome also refuses it as root, where that opt-out is the only way to run.
344 const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment);
345 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
346
347 /** Runs one browser launch; a host that cannot start it gets setup steps instead. */
348 const launching = <A>(
349 executable: string,
350 run: (noSandbox: boolean) => Effect.Effect<A, HeadlessChrome.HtmlRenderBrowserError>,
351 ) =>
352 browsers.withPermits(1)(
353 run(noSandbox).pipe(
354 Effect.catchTags({
355 HtmlRenderBrowserError: (error) =>
356 error.output === undefined
357 ? Effect.fail(error)
358 : PreviewBrowserHost.diagnoseLaunchFailure({
359 executable,
360 setupCommand,
361 output: error.output,
362 }).pipe(
363 Effect.provideContext(services),
364 Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
365 ),
366 }),
367 ),
368 );
369
370 // Never installs the browser: html_render must not depend on it.
371 const measure = (html: string) =>
372 Effect.gen(function* () {
373 const executable = yield* previewBrowser.installed;
374 if (Option.isNone(executable)) return undefined;
375 const heights = yield* launching(executable.value, (noSandbox) =>
376 HeadlessChrome.measureHtmlHeights({
377 executable: executable.value,
378 noSandbox,
379 html,
380 widths: HTML_RENDER_MEASURE_WIDTHS,
381 urlFragment: MEASURE_FRAGMENT,
382 }).pipe(Effect.provideContext(services)),
383 ).pipe(
384 Effect.timeoutOrElse({
385 duration: MEASURE_TIMEOUT,
386 orElse: () =>
387 Effect.fail(
388 new HeadlessChrome.HtmlRenderBrowserError({
389 reason: `measuring took longer than ${MEASURE_TIMEOUT}`,
390 }),
391 ),
392 }),
393 );
394 return heights.toSorted(([left], [right]) => left - right);
395 }).pipe(
396 Effect.catch((cause) =>
397 Effect.logWarning("Could not measure an HTML render; publishing it without heights.", {
398 cause,
399 }).pipe(Effect.as(undefined)),
400 ),
401 Effect.withSpan("HtmlRender.measure"),
402 );
403
404 // The bootstrap goes in first so its head scan never runs over inlined image data.
405 const inline = (html: string) =>
406 inlineLocalImages(injectHtmlRenderBootstrap(html)).pipe(Effect.provideContext(services));
407
408 const prepare = Effect.fn("HtmlRender.prepare")(function* (html: string) {
409 const inlined = yield* inline(html);
410 if (inlined.missing.length > 0) {
411 return yield* new HtmlRenderImagesNotFoundError({ paths: inlined.missing });
412 }
413 return inlined.html;
414 });
415
416 const publish = Effect.fn("HtmlRender.publish")(function* (input: {
417 readonly threadId: ThreadId;
418 readonly html: string;
419 readonly title: string;
420 readonly height: number;
421 }) {
422 const html = yield* prepare(input.html);
423 // The `-html` id suffix makes the asset route serve it as a sandboxed text/html document.
424 const attachmentId = createAttachmentId(input.threadId, "html");
425 const filePath =
426 attachmentId === null
427 ? null
428 : resolveAttachmentRelativePath({
429 attachmentsDir: config.attachmentsDir,
430 relativePath: `${attachmentId}.html`,
431 });
432 if (attachmentId === null || filePath === null) {
433 return yield* new HtmlRenderStoreError({ cause: new Error("Invalid thread id.") });
434 }
435 const heights = yield* fileSystem.writeFileString(filePath, html).pipe(
436 Effect.mapError((cause) => new HtmlRenderStoreError({ cause })),
437 Effect.andThen(measure(html)),
438 // Only the returned reference lets thread deletion find the page, so a
439 // publish that fails or is interrupted before returning removes it.
440 Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)),
441 );
442 return {
443 attachmentId,
444 title: input.title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML",
445 height: clampHtmlRenderHeight(input.height),
446 ...(heights === undefined ? {} : { heights }),
447 } satisfies HtmlRenderReference;
448 });
449
450 const preview = Effect.fn("HtmlRender.preview")(function* (input: {
451 readonly html: string;
452 readonly width?: number | undefined;
453 readonly appearance?: ThemeAppearance | undefined;
454 }) {
455 const width = Math.min(
456 MAX_PREVIEW_WIDTH,
457 Math.max(MIN_PREVIEW_WIDTH, Math.round(input.width ?? HTML_RENDER_COLUMN_WIDTH)),
458 );
459 const appearance = input.appearance ?? "dark";
460 const inlined = yield* inline(input.html);
461 const executable = yield* previewBrowser.executable;
462 const theme = htmlRenderTheme(
463 appearance === "light" ? T3_CODE_LIGHT_THEME_COLORS : T3_CODE_DARK_THEME_COLORS,
464 appearance,
465 HTML_RENDER_MEASURE_FONTS,
466 );
467 const screenshot = yield* launching(executable, (noSandbox) =>
468 HeadlessChrome.captureHtmlScreenshot({
469 executable,
470 noSandbox,
471 html: inlined.html,
472 width,
473 urlFragment: htmlRenderThemeFragment(theme),
474 }).pipe(Effect.provideContext(services)),
475 );
476 return {
477 ...screenshot,
478 width,
479 ...(inlined.missing.length === 0 ? {} : { missingImages: inlined.missing }),
480 } satisfies HtmlPreview;
481 });
482
483 return HtmlRender.of({ prepare, publish, preview });
484});
485
486export const layer = Layer.effect(HtmlRender, make);