From 14bdb440a8869b41f07a593e91ec3eba590ad7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=88=9A=28noham=29=C2=B2?= <100566912+NohamR@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:52:42 +0200 Subject: [PATCH] Add server mode --- .dockerignore | 9 ++++ Dockerfile | 33 ++++++++++++ README.md | 37 +++++++++++++ docker-compose.yml | 10 ++++ package.json | 1 + src/server.js | 128 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd5e775 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +output +extension/reader-view +extension/*.crx +extension/*.zip +.git +.gitignore +.DS_Store +*~ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd8357a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:22-bookworm-slim + +# Install Chromium +RUN apt-get update && apt-get install -y \ + chromium \ + unzip \ + ca-certificates \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Tell Puppeteer to use the system Chromium instead of downloading its own +ENV PUPPETEER_SKIP_BROWSER_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY . . + +# Pre-download the Reader View extension so it works offline at runtime +RUN node src/download-extension.js + +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8080 + +ENV PORT=8080 +ENV LOG_LEVEL=info + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md index 47b86a5..c8b4183 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,43 @@ try { **`BotProtectionError`** — thrown when the target page appears to be behind bot protection. +## HTTP Server + +Run as a headless service via Docker or directly with Node: + +```bash +# Start server +npm run start:server + +# Or via Docker +docker compose up -d +``` + +### Endpoints + +``` +POST /convert Generate a PDF from a URL +GET /health Health check (browser status, uptime) +GET / Help page +``` + +### Usage + +```bash +curl -X POST http://localhost:8080/convert \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com/article","theme":"dark"}' \ + -o article.pdf +``` + +| Body field | Type | Description | +|------------|------|-------------| +| `url` | string | Article URL (required unless `html` is provided) | +| `theme` | string | Reader View theme | +| `css` | string | Custom CSS string | +| `html` | string | Pre-fetched HTML (bypasses URL fetch) | +| `prefs` | object | Extension preferences | + ## Credits - [Reader View](https://chromewebstore.google.com/detail/reader-view/ecabifbgmdmgdllomnfinbmaellmclnh) Chrome extension diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf03071 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + pdfy: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + - LOG_LEVEL=info + restart: unless-stopped + init: true diff --git a/package.json b/package.json index df64cbe..37b1135 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "start": "node src/index.js", + "start:server": "node src/server.js", "test": "node src/test/test.js" }, "standard": { diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..bfe26fc --- /dev/null +++ b/src/server.js @@ -0,0 +1,128 @@ +import { createServer } from 'node:http' +import process from 'node:process' +import { convert, launchBrowser, BotProtectionError, VALID_THEMES } from './pdfy.js' +import logger from './logger.js' + +const PORT = parseInt(process.env.PORT || '8080', 10) +const HOST = process.env.HOST || '0.0.0.0' + +let browser + +async function parseBody (req) { + const chunks = [] + for await (const chunk of req) { + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString()) +} + +function sendJson (res, status, data) { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(data)) +} + +async function handleConvert (req, res) { + let body + try { + body = await parseBody(req) + } catch { + return sendJson(res, 400, { error: 'Invalid JSON body' }) + } + + const { url, theme, css, prefs, html } = body + + if (!url && !html) { + return sendJson(res, 400, { error: 'Missing required field: url (or html for direct content)' }) + } + + if (theme && !VALID_THEMES.includes(theme)) { + return sendJson(res, 400, { + error: `Invalid theme. Valid themes: ${VALID_THEMES.join(', ')}` + }) + } + + try { + const result = await convert(url || 'about:blank', { + browser, + html: html || null, + theme: theme || 'light', + css: css || null, + prefs: prefs || {} + }) + + const sizeKb = (result.pdfBuffer.length / 1024).toFixed(0) + logger.info(`Converted: "${result.title}" (${sizeKb} KB)`) + + res.writeHead(200, { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(result.title)}.pdf"`, + 'Content-Length': result.pdfBuffer.length + }) + res.end(result.pdfBuffer) + } catch (err) { + if (err instanceof BotProtectionError) { + return sendJson(res, 400, { + error: 'Bot protection detected.', + hint: 'Pass the page HTML directly via the "html" field.' + }) + } + logger.error(`Conversion failed: ${err.message}`) + sendJson(res, 500, { error: err.message }) + } +} + +const server = createServer((req, res) => { + const { method, url } = req + + if (method === 'POST' && url === '/convert') { + return handleConvert(req, res) + } + + if (method === 'GET' && url === '/health') { + return sendJson(res, 200, { + status: 'ok', + browser: browser ? 'connected' : 'disconnected', + uptime: process.uptime() + }) + } + + if (method === 'GET' && url === '/') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end(` +pdfy server + +POST /convert Convert a URL to PDF + Body: { "url": "...", "theme": "...", "css": "...", "html": "..." } + +GET /health Health check +GET / This help +`) + return + } + + sendJson(res, 404, { error: 'Not found' }) +}) + +async function start () { + logger.info('Launching browser...') + browser = await launchBrowser() + logger.info('Browser ready') + + server.listen(PORT, HOST, () => { + logger.info(`Server listening on http://${HOST}:${PORT}`) + }) +} + +async function shutdown () { + logger.info('Shutting down...') + if (browser) await browser.close() + server.close(() => process.exit(0)) +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) + +start().catch((err) => { + logger.error(`Failed to start: ${err.message}`) + process.exit(1) +})