Add server mode

This commit is contained in:
√(noham)²
2026-06-19 21:52:42 +02:00
parent 54cb28c488
commit 14bdb440a8
6 changed files with 218 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
output
extension/reader-view
extension/*.crx
extension/*.zip
.git
.gitignore
.DS_Store
*~

33
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
pdfy:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- LOG_LEVEL=info
restart: unless-stopped
init: true

View File

@@ -10,6 +10,7 @@
},
"scripts": {
"start": "node src/index.js",
"start:server": "node src/server.js",
"test": "node src/test/test.js"
},
"standard": {

128
src/server.js Normal file
View File

@@ -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)
})