Skip to content

Commit 78cf0f6

Browse files
authored
fix(backend): headless browser install, if chrome not found (#31)
1 parent 15fe388 commit 78cf0f6

12 files changed

Lines changed: 264 additions & 59 deletions

File tree

backend/bun/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@
88
"format": "prettier --write .",
99
"prepublishOnly": "npm run build && npm run lint"
1010
},
11-
"private": true
11+
"private": true,
12+
"devDependencies": {
13+
"@types/puppeteer": "7.0.4"
14+
}
1215
}
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
import fs from "fs";
2-
import nodeHtmlToImage from "node-html-to-image";
3-
41
import type {
52
JSONObjectHTMLSuccessRequest,
63
JSONObjectImageSuccessRequest,
74
NodeHTMLToImageBuffer,
85
} from "./../types";
96
import { HTMLGenerator } from ".";
107
import { getFullOutputPath } from "../utils/file";
8+
import { setupPuppeteer } from "../utils/puppeteer";
9+
import { htmlToImage } from "../utils/htmlToImage";
1110

1211
export const ImageGenerator = async (
1312
json: JSONObjectImageSuccessRequest,
1413
): Promise<[NodeHTMLToImageBuffer, string]> => {
14+
// Setup Puppeteer cache directory and ensure browser is installed
15+
// This must be done before generating the image
16+
// Returns the executable path if browser was installed
17+
const executablePath = await setupPuppeteer();
18+
1519
// HTMLGenerator already handles font size conversion and generates the HTML
1620
const [code] = await HTMLGenerator(
1721
json as unknown as JSONObjectHTMLSuccessRequest,
@@ -24,14 +28,15 @@ export const ImageGenerator = async (
2428
);
2529

2630
const filepath = outputFilepath + "." + json.data.outputImageFormat;
27-
// The HTML is already fully generated with correct font sizes, so we just pass it to nodeHtmlToImage
28-
return [
29-
await nodeHtmlToImage({
30-
output: filepath,
31-
html: code,
32-
transparent: json.data.transparent,
33-
type: json.data.outputImageFormat,
34-
}),
35-
filepath,
36-
];
31+
// Convert HTML to image using our custom Puppeteer implementation
32+
// Pass the executable path explicitly to ensure Puppeteer uses the installed browser
33+
const buffer = await htmlToImage({
34+
output: filepath,
35+
html: code,
36+
transparent: json.data.transparent,
37+
type: json.data.outputImageFormat,
38+
executablePath: executablePath,
39+
});
40+
41+
return [buffer, filepath];
3742
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import puppeteer from "puppeteer";
2+
import type { ScreenshotOptions, LaunchOptions } from "puppeteer";
3+
4+
export interface HtmlToImageOptions {
5+
html: string;
6+
output?: string;
7+
transparent?: boolean;
8+
type?: "png" | "jpeg" | "webp";
9+
quality?: number;
10+
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
11+
executablePath?: string | null;
12+
}
13+
14+
/**
15+
* Converts HTML to an image using Puppeteer.
16+
* @param options - Configuration options for the image generation
17+
* @returns Buffer containing the image data
18+
*/
19+
export async function htmlToImage(
20+
options: HtmlToImageOptions,
21+
): Promise<Buffer> {
22+
const {
23+
html,
24+
output,
25+
transparent = false,
26+
type = "png",
27+
quality = 90,
28+
waitUntil = "networkidle2",
29+
executablePath,
30+
} = options;
31+
32+
// Launch browser with appropriate settings
33+
const launchOptions: LaunchOptions = {
34+
headless: true,
35+
args: [
36+
"--no-sandbox",
37+
"--disable-setuid-sandbox",
38+
"--disable-dev-shm-usage",
39+
"--disable-gpu",
40+
],
41+
};
42+
43+
// If executable path is provided, use it explicitly
44+
if (executablePath) {
45+
launchOptions.executablePath = executablePath;
46+
}
47+
48+
const browser = await puppeteer.launch(launchOptions);
49+
50+
try {
51+
const page = await browser.newPage();
52+
53+
// Set content with HTML
54+
await page.setContent(html, {
55+
waitUntil: waitUntil,
56+
});
57+
58+
// Configure screenshot options
59+
const screenshotOptions: ScreenshotOptions = {
60+
type: type,
61+
fullPage: true,
62+
omitBackground: transparent,
63+
};
64+
65+
// Add quality for JPEG/WebP
66+
if (type === "jpeg" || type === "webp") {
67+
screenshotOptions.quality = quality;
68+
}
69+
70+
// Take screenshot - get buffer directly for efficiency
71+
const screenshotBuffer = await page.screenshot({
72+
...screenshotOptions,
73+
...(output && { path: output }),
74+
});
75+
76+
// Convert to Buffer if needed
77+
const buffer =
78+
screenshotBuffer instanceof Buffer
79+
? screenshotBuffer
80+
: Buffer.from(screenshotBuffer);
81+
82+
return buffer;
83+
} finally {
84+
await browser.close();
85+
}
86+
}

backend/bun/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from "./stdin.ts";
22
export * from "./clipboard.ts";
33
export * from "./display.ts";
4+
export * from "./puppeteer.ts";
5+
export * from "./htmlToImage.ts";

backend/bun/src/utils/puppeteer.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { mkdir } from "node:fs/promises";
2+
import path from "node:path";
3+
import puppeteer from "puppeteer";
4+
import os from "node:os";
5+
import { existsSync } from "node:fs";
6+
import {
7+
Browser,
8+
BrowserPlatform,
9+
BrowserTag,
10+
Cache,
11+
computeExecutablePath,
12+
install,
13+
resolveBuildId,
14+
} from "@puppeteer/browsers";
15+
import { writeJSONToStdout } from "./stdin";
16+
17+
/**
18+
* Gets the executable path for the installed Chrome browser.
19+
* @param cacheDir - The cache directory where browsers are stored
20+
* @returns The executable path, or null if not found
21+
*/
22+
const getInstalledBrowserPath = (cacheDir: string): string | null => {
23+
try {
24+
const platformMap: Record<string, BrowserPlatform> = {
25+
win32: BrowserPlatform.WIN32,
26+
darwin: BrowserPlatform.MAC,
27+
linux: BrowserPlatform.LINUX,
28+
};
29+
30+
const currentPlatform = process.platform;
31+
const browserPlatform =
32+
platformMap[currentPlatform] || BrowserPlatform.LINUX;
33+
34+
// Try to get the latest installed browser
35+
const cache = new Cache(cacheDir);
36+
const installedBrowsers = cache.getInstalledBrowsers();
37+
const chromeBrowser = installedBrowsers.find(
38+
(b) => b.browser === Browser.CHROME && b.platform === browserPlatform,
39+
);
40+
41+
if (chromeBrowser) {
42+
return computeExecutablePath({
43+
cacheDir: cacheDir,
44+
browser: Browser.CHROME,
45+
platform: browserPlatform,
46+
buildId: chromeBrowser.buildId,
47+
});
48+
}
49+
50+
return null;
51+
} catch (/* eslint-disable-line */ error) {
52+
return null;
53+
}
54+
};
55+
56+
/**
57+
* Sets up Puppeteer cache directory and ensures browser is installed.
58+
* This is necessary when running in a bundled binary where the default
59+
* cache path might not be accessible.
60+
* Uses platform-specific default cache directories:
61+
* - Windows: %LOCALAPPDATA%\puppeteer
62+
* - macOS: ~/Library/Caches/puppeteer
63+
* - Linux: ~/.cache/puppeteer
64+
* @returns The executable path of the installed browser, or null if not found
65+
*/
66+
export const setupPuppeteer = async (): Promise<string | null> => {
67+
// Use platform-specific default cache directory
68+
const cacheDir = path.join(os.homedir(), ".cache", "puppeteer");
69+
70+
// Ensure the cache directory exists
71+
await mkdir(cacheDir, { recursive: true });
72+
73+
// Check if browser is installed
74+
let executablePath: string | null = null;
75+
let needsInstall = false;
76+
77+
// First, try to get the executable path from installed browsers
78+
executablePath = getInstalledBrowserPath(cacheDir);
79+
80+
// If not found, try Puppeteer's method
81+
if (!executablePath) {
82+
try {
83+
executablePath = puppeteer.executablePath();
84+
// Check if the executable actually exists
85+
if (!executablePath || !existsSync(executablePath)) {
86+
needsInstall = true;
87+
}
88+
} catch (/* eslint-disable-line */ error: unknown) {
89+
// Browser is not installed, we'll try to install it below
90+
needsInstall = true;
91+
}
92+
}
93+
94+
if (needsInstall) {
95+
try {
96+
const platformMap: Record<string, BrowserPlatform> = {
97+
win32: BrowserPlatform.WIN32,
98+
darwin: BrowserPlatform.MAC,
99+
linux: BrowserPlatform.LINUX,
100+
};
101+
102+
const currentPlatform = process.platform;
103+
const browserPlatform =
104+
platformMap[currentPlatform] || BrowserPlatform.LINUX;
105+
106+
const buildId = await resolveBuildId(
107+
Browser.CHROME,
108+
browserPlatform,
109+
BrowserTag.LATEST,
110+
);
111+
112+
// Install the browser with the correct buildId
113+
// This may take 30-60 seconds, so ensure timeout is set appropriately
114+
await install({
115+
browser: Browser.CHROME,
116+
buildId: buildId,
117+
cacheDir: cacheDir,
118+
});
119+
120+
// After installation, get the executable path
121+
executablePath = computeExecutablePath({
122+
cacheDir: cacheDir,
123+
browser: Browser.CHROME,
124+
platform: browserPlatform,
125+
buildId: buildId,
126+
});
127+
} catch (installError) {
128+
writeJSONToStdout({
129+
success: false,
130+
error: "Error installing Chrome browser",
131+
context:
132+
installError instanceof Error
133+
? installError.message
134+
: String(installError),
135+
});
136+
}
137+
}
138+
139+
return executablePath;
140+
};

0 commit comments

Comments
 (0)