CoolFace
Apppublic

sonli2026/layoutly

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
server.js339 linesDownload Raw Back to root
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