sonli2026/layoutly
0
1const express = require('express');2const cors = require('cors');3const puppeteer = require('puppeteer');4const fs = require('fs');5const crypto = require('crypto');6 7const app = express();8const port = Number(process.env.PORT) || 7860;9const startedAt = Date.now();10const allowedFormats = new Set(['png', 'jpeg', 'webp']);11const allowedOrigins = [12 /^https:\/\/layoutly\.pages\.dev$/i,13 /^https:\/\/[a-z0-9-]+\.layoutly\.pages\.dev$/i,14 /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i15];16 17app.disable('x-powered-by');18app.use(cors({19 origin(origin, callback) {20 if (!origin || allowedOrigins.some(pattern => pattern.test(origin))) return callback(null, true);21 return callback(new Error('Origin is not allowed.'));22 },23 methods: ['GET', 'POST', 'OPTIONS'],24 allowedHeaders: ['Content-Type'],25 maxAge: 8640026}));27app.use(express.json({ limit: '16mb', strict: true }));28 29let browserInstance = null;30let browserLaunchPromise = null;31 32function findBrowserExecutable() {33 const candidates = [34 process.env.PUPPETEER_EXECUTABLE_PATH,35 '/usr/bin/chromium',36 '/usr/bin/chromium-browser',37 '/usr/bin/google-chrome-stable',38 '/usr/bin/google-chrome',39 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',40 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',41 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'42 ].filter(Boolean);43 const installed = candidates.find(candidate => fs.existsSync(candidate));44 if (installed) return installed;45 try {46 const bundled = puppeteer.executablePath();47 return fs.existsSync(bundled) ? bundled : undefined;48 } catch {49 return undefined;50 }51}52 53async function initBrowser() {54 if (browserInstance?.connected) return browserInstance;55 if (browserLaunchPromise) return browserLaunchPromise;56 57 browserLaunchPromise = (async () => {58 const executablePath = findBrowserExecutable();59 if (!executablePath) throw new Error('No Chromium executable is available.');60 console.log(JSON.stringify({ event: 'browser_launch', executablePath }));61 const browser = await puppeteer.launch({62 headless: true,63 executablePath,64 args: [65 '--no-sandbox',66 '--disable-setuid-sandbox',67 '--disable-dev-shm-usage',68 '--disable-gpu',69 '--no-first-run',70 '--no-zygote',71 '--font-render-hinting=medium'72 ]73 });74 browser.on('disconnected', () => {75 browserInstance = null;76 console.warn(JSON.stringify({ event: 'browser_disconnected' }));77 });78 browserInstance = browser;79 console.log(JSON.stringify({ event: 'browser_ready' }));80 return browser;81 })().finally(() => {82 browserLaunchPromise = null;83 });84 85 return browserLaunchPromise;86}87 88class RenderQueue {89 constructor(concurrency, maxQueued) {90 this.concurrency = concurrency;91 this.maxQueued = maxQueued;92 this.active = 0;93 this.queue = [];94 }95 96 enqueue(task) {97 if (this.queue.length >= this.maxQueued) {98 const error = new Error('The renderer is busy. Please retry shortly.');99 error.statusCode = 503;100 throw error;101 }102 return new Promise((resolve, reject) => {103 this.queue.push({ task, resolve, reject });104 this.process();105 });106 }107 108 async process() {109 while (this.active < this.concurrency && this.queue.length > 0) {110 const { task, resolve, reject } = this.queue.shift();111 this.active += 1;112 task().then(resolve, reject).finally(() => {113 this.active -= 1;114 this.process();115 });116 }117 }118}119 120const renderQueue = new RenderQueue(2, 12);121const rateWindows = new Map();122 123function rateLimit(req, res, next) {124 const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();125 const key = forwarded || req.ip || 'unknown';126 const now = Date.now();127 const windowMs = 10 * 60 * 1000;128 const existing = rateWindows.get(key);129 const entry = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;130 entry.count += 1;131 rateWindows.set(key, entry);132 if (entry.count > 24) {133 res.set('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)));134 return res.status(429).json({ error: 'Too many render requests. Please retry later.' });135 }136 next();137}138 139function parseInteger(value, fallback) {140 const parsed = Number.parseInt(value, 10);141 return Number.isFinite(parsed) ? parsed : fallback;142}143 144function getRenderGeometry(body) {145 const width = parseInteger(body.width, 1200);146 const height = parseInteger(body.height, 800);147 if (width < 64 || height < 64 || width > 8192 || height > 8192) {148 const error = new Error('Width and height must be between 64 and 8192 pixels.');149 error.statusCode = 400;150 throw error;151 }152 153 const requestedScale = Math.max(0.1, Number.parseFloat(body.dpr) || 1);154 const maxDimensionScale = 16384 / Math.max(width, height);155 const maxAreaScale = Math.sqrt(268435456 / (width * height));156 const scale = Math.max(0.1, Math.min(requestedScale, maxDimensionScale, maxAreaScale));157 return {158 width,159 height,160 scale,161 outputWidth: Math.max(1, Math.round(width * scale)),162 outputHeight: Math.max(1, Math.round(height * scale))163 };164}165 166function contentTypeFor(format) {167 if (format === 'jpeg') return 'image/jpeg';168 if (format === 'webp') return 'image/webp';169 return 'image/png';170}171 172async function configurePage(page, geometry) {173 page.setDefaultTimeout(20000);174 page.setDefaultNavigationTimeout(25000);175 await page.setRequestInterception(true);176 page.on('request', request => {177 try {178 const protocol = new URL(request.url()).protocol;179 if (['https:', 'data:', 'blob:', 'about:'].includes(protocol)) return request.continue();180 } catch {181 if (request.url() === 'about:blank') return request.continue();182 }183 return request.abort('blockedbyclient');184 });185 await page.setViewport({186 width: geometry.width,187 height: geometry.height,188 deviceScaleFactor: geometry.scale189 });190 await page.emulateMediaType('screen');191}192 193function stripExecutableMarkup(html) {194 return String(html)195 .replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '')196 .replace(/<script\b[^>]*\/\s*>/gi, '')197 .replace(/<meta\b[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*>/gi, '')198 .replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')199 .replace(/\bjavascript\s*:/gi, '');200}201 202async function settleAssets(page) {203 await page.evaluate(async () => {204 const bounded = (promise, timeout) => Promise.race([205 promise,206 new Promise(resolve => setTimeout(resolve, timeout))207 ]);208 if (document.fonts?.ready) await bounded(document.fonts.ready, 8000);209 const images = Array.from(document.images || []);210 await bounded(Promise.all(images.map(async image => {211 if (!image.complete) {212 await new Promise(resolve => {213 image.addEventListener('load', resolve, { once: true });214 image.addEventListener('error', resolve, { once: true });215 });216 }217 try { if (image.decode) await image.decode(); } catch {}218 })), 10000);219 await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));220 });221}222 223app.get('/', (req, res) => {224 res.json({225 service: 'Layoutly Rendering API',226 status: 'running',227 uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),228 queue: { active: renderQueue.active, waiting: renderQueue.queue.length }229 });230});231 232app.get('/health', async (req, res) => {233 try {234 if (req.query.deep === '1') await initBrowser();235 res.json({236 ok: true,237 browser: browserInstance?.connected ? 'ready' : 'cold',238 queue: { active: renderQueue.active, waiting: renderQueue.queue.length }239 });240 } catch (error) {241 res.status(503).json({ ok: false, error: error.message });242 }243});244 245app.post('/export', rateLimit, async (req, res) => {246 const requestStartedAt = Date.now();247 const requestId = crypto.randomUUID().slice(0, 8);248 try {249 const html = typeof req.body?.html === 'string' ? req.body.html : '';250 if (!html.trim()) return res.status(400).json({ error: 'Missing HTML content.' });251 const format = allowedFormats.has(req.body.format) ? req.body.format : 'png';252 const geometry = getRenderGeometry(req.body);253 const safeHtml = stripExecutableMarkup(html);254 console.log(JSON.stringify({ event: 'render_queued', requestId, format, ...geometry }));255 256 const result = await renderQueue.enqueue(async () => {257 let page;258 let deadlineTimer;259 try {260 const work = (async () => {261 const browser = await initBrowser();262 page = await browser.newPage();263 await configurePage(page, geometry);264 console.log(JSON.stringify({ event: 'render_stage', requestId, stage: 'page_ready' }));265 await page.setContent(safeHtml, { waitUntil: 'domcontentloaded', timeout: 25000 });266 await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}' });267 await page.waitForNetworkIdle({ idleTime: 350, timeout: 5000 }).catch(() => {});268 console.log(JSON.stringify({ event: 'render_stage', requestId, stage: 'dom_ready' }));269 await settleAssets(page);270 console.log(JSON.stringify({ event: 'render_stage', requestId, stage: 'assets_ready' }));271 272 const screenshotOptions = {273 type: format,274 clip: { x: 0, y: 0, width: geometry.width, height: geometry.height },275 captureBeyondViewport: false,276 omitBackground: format !== 'jpeg'277 };278 if (format !== 'png') screenshotOptions.quality = 92;279 const buffer = await page.screenshot(screenshotOptions);280 console.log(JSON.stringify({ event: 'render_stage', requestId, stage: 'captured', bytes: buffer.length }));281 return buffer;282 })();283 284 const deadline = new Promise((resolve, reject) => {285 deadlineTimer = setTimeout(async () => {286 const error = new Error('Render timed out.');287 error.statusCode = 504;288 if (page) await page.close().catch(() => {});289 reject(error);290 }, 45000);291 });292 return await Promise.race([work, deadline]);293 } finally {294 if (deadlineTimer) clearTimeout(deadlineTimer);295 if (page) await page.close().catch(() => {});296 }297 });298 299 res.set({300 'Content-Type': contentTypeFor(format),301 'Cache-Control': 'no-store',302 'X-Content-Type-Options': 'nosniff',303 'X-Layoutly-Width': String(geometry.outputWidth),304 'X-Layoutly-Height': String(geometry.outputHeight),305 'X-Layoutly-Render-Ms': String(Date.now() - requestStartedAt)306 });307 console.log(JSON.stringify({ event: 'render_complete', requestId, elapsedMs: Date.now() - requestStartedAt }));308 res.send(result);309 } catch (error) {310 const statusCode = Number(error.statusCode) || 500;311 console.error(JSON.stringify({ event: 'render_error', requestId, statusCode, message: error.message }));312 res.status(statusCode).json({ error: statusCode >= 500 ? 'Failed to render image.' : error.message });313 }314});315 316app.use((error, req, res, next) => {317 if (res.headersSent) return next(error);318 const statusCode = error.type === 'entity.too.large' ? 413 : 400;319 res.status(statusCode).json({ error: statusCode === 413 ? 'Render payload is too large.' : error.message });320});321 322const server = app.listen(port, '0.0.0.0', () => {323 console.log(JSON.stringify({ event: 'server_ready', port }));324 initBrowser().catch(error => console.error(JSON.stringify({ event: 'browser_warmup_failed', message: error.message })));325});326 327async function shutdown(signal) {328 console.log(JSON.stringify({ event: 'shutdown', signal }));329 server.close();330 if (browserInstance) await browserInstance.close().catch(() => {});331 process.exit(0);332}333 334process.on('SIGTERM', () => shutdown('SIGTERM'));335process.on('SIGINT', () => shutdown('SIGINT'));336process.on('unhandledRejection', error => {337 console.error(JSON.stringify({ event: 'unhandled_rejection', message: error?.message || String(error) }));338});339 