Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/reuse-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ jobs:
node-version-file: .nvmrc
cache: 'npm'

# google-chrome-stable is preinstalled on the runner, gifsicle is not
- name: Install gifsicle
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gifsicle

- name: Install dependencies
run: npm ci

# the browser of the tests, the version that matches puppeteer
- name: Install chrome-headless-shell
run: npx puppeteer browsers install chrome-headless-shell

- name: Lint
run: npm run lint

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ docs/superpowers/
.superpowers/
.playwright-mcp/

benchmark/results/*
!benchmark/results/.gitkeep
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## Project overview

Capture renders Web pages with puppeteer (google-chrome) and returns screenshots (png, jpg,
Capture renders Web pages with puppeteer (chrome-headless-shell) and returns screenshots (png, jpg,
animated gif through gifsicle) or prints (pdf, html). API routes in `api/routers/capture.ts`,
browser contexts pool in `api/utils/page.ts`, config in `config/` (types generated in
`config/type/` by `npm run build-types`).
browser and capture slots in `api/utils/page.ts` (one fresh incognito context per capture), config
in `config/` (types generated in `config/type/` by `npm run build-types`).

Node.js v24, TypeScript run directly by Node's type stripping (no build step, erasable syntax
only), Express 5.
Expand Down Expand Up @@ -42,7 +42,8 @@ Branch names: letters, digits, `.`, `_`, `-`, no `/`, and `capture_<branch>` at
## Testing

`node:test` suites in `test-it/`, they start the API in-process on `TEST_API_PORT` (`.env`) and
need google-chrome-stable and gifsicle on the machine.
need gifsicle on the machine and puppeteer's chrome-headless-shell
(`npx puppeteer browsers install chrome-headless-shell`).

```bash
npm test # all tests
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@

- Node.js, the version of `.nvmrc` (start zellij from a shell on it: the panes inherit its PATH)
- zellij >= 0.44
- google-chrome-stable (`/usr/bin/google-chrome-stable`, the `executablePath` of `config/default.cjs`)
and gifsicle (animated captures), installed on your system
- gifsicle (animated captures), installed on your system. The browser is chrome-headless-shell:
the image installs Debian's package, development and tests use the build downloaded by puppeteer
(`npx puppeteer browsers install chrome-headless-shell`, the version that matches puppeteer)

## Setup

The dev environment is the shared one of the data-fair services (`@data-fair/lib-dev`): a zellij
session per checkout, described by `.zellij.kdl` and `dev/dev.json`. No containers: in development
capture runs without authentication (no `privateDirectoryUrl`).

1. Install dependencies (puppeteer's own browser download is not needed), then write `.env` (free
ports drawn for this checkout) and build the types:
1. Install dependencies and chrome-headless-shell, then write `.env` (free ports drawn for this
checkout) and build the types:
```bash
PUPPETEER_SKIP_DOWNLOAD=true npm ci
npx puppeteer browsers install chrome-headless-shell
npx --no df-dev setup
```

Expand Down
17 changes: 8 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@ FROM base AS nativedeps
ARG TARGETARCH
RUN echo "Building for architecture $TARGETARCH"

# Install chrome and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Install chromium-headless-shell (Debian's build of the "old" headless Chromium, amd64 and arm64,
# security updates with the rest of the distribution) and fonts to support major charsets
# (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y wget gnupg ca-certificates && \
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \
sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' && \
apt-get update && \
apt-get install -y google-chrome-stable gifsicle fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst-one fonts-freefont-ttf libxss1 dbus dbus-x11 --no-install-recommends && \
apt-get install -y chromium-headless-shell gifsicle wget ca-certificates fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst-one fonts-freefont-ttf dbus dbus-x11 --no-install-recommends && \
service dbus start

ENV DBUS_SESSION_BUS_ADDRESS=autolaunch:
Expand All @@ -37,9 +35,10 @@ RUN case "$TARGETARCH" in \
wget -q -O /usr/local/bin/dumb-init "https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_$DUMB_INIT_ARCH" && \
chmod +x /usr/local/bin/dumb-init

# cleanup (wget stays: google-chrome-stable depends on it, purging it removes chrome)
RUN apt-get clean
RUN apt-get purge -y --auto-remove gnupg apt-transport-https
# cleanup: wget was only needed to download dumb-init
RUN apt-get purge -y --auto-remove wget && \
test -x /usr/lib/chromium/chromium-headless-shell && \
apt-get clean && rm -rf /var/lib/apt/lists/*

# skip the browser download when installing puppeteer
ENV PUPPETEER_SKIP_DOWNLOAD=true
Expand Down
58 changes: 41 additions & 17 deletions api/routers/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ import type { PaperFormat, PDFOptions } from 'puppeteer'

const debug = debugModule('capture')

// /inspect renders are expensive, a small number of them run at the same time
const inspectSemaphore = createSemaphore(config.inspectConcurrency)
// /inspect renders are expensive, a small number of them run at the same time (each one then takes
// a capture slot like the other routes)
const inspectSemaphore = createSemaphore(config.inspectConcurrency, config.queue)

export const router = express.Router()

// aborted when the client closes the request before its response is sent (a proxy timeout, a
// user who left): the capture is dropped instead of being rendered for nobody
const clientSignal = (res: Response) => {
const controller = new AbortController()
if (res.destroyed) controller.abort()
else res.on('close', () => { if (!res.writableFinished) controller.abort() })
return controller.signal
}

async function auth (req: Request, res: Response, next: NextFunction) {
if (config.privateDirectoryUrl) {
const user = reqSession(req).user
Expand Down Expand Up @@ -113,18 +123,27 @@ router.get('/screenshot', auth, async (req, res, next) => {
if (typeof req.query.filename === 'string') res.attachment(req.query.filename.replace('.png', '.gif'))
res.send(buffer)
} else {
const buffer = await page.screenshot({ fullPage: req.query.height === 'auto' })
debug(`[${target}] png screenshot is taken`)
timer.type = 'png'
// a gif was asked from a page that does not support animation: a png is returned
const imageType = (type === 'jpg' || type === 'jpeg') ? 'jpeg' : 'png'
const extension = imageType === 'jpeg' ? type : 'png'
const fullPage = req.query.height === 'auto'
// png encoding runs on the browser's main thread, shared by all captures: the large
// captures are encoded for speed (cf benchmark/FINDINGS.md)
const optimizeForSpeed = imageType === 'png' && (fullPage || width * height > config.pngFastEncodingPixels)
const buffer = await page.screenshot({ fullPage, type: imageType, optimizeForSpeed })
debug(`[${target}] ${imageType} screenshot is taken`)
timer.type = imageType
if (req.query.timer === 'true') {
res.send(timer.times)
} else {
res.type(type)
if (typeof req.query.filename === 'string') res.attachment(req.query.filename.replace('.gif', '.' + type))
res.type(extension)
if (typeof req.query.filename === 'string') res.attachment(req.query.filename.replace(/\.(gif|png|jpe?g)$/, '.' + extension))
res.send(buffer)
}
}
}
},
undefined,
clientSignal(res)
)
timer.finish()
})
Expand Down Expand Up @@ -191,7 +210,9 @@ router.get('/print', auth, async (req, res, next) => {
if (typeof req.query.filename === 'string') res.attachment(req.query.filename)
res.send(buffer.toString())
}
}
},
undefined,
clientSignal(res)
)
timer.finish()
})
Expand Down Expand Up @@ -246,9 +267,9 @@ router.get('/inspect', async (req, res) => {
const timer = createTimer(req.originalUrl, 'inspect')

let collector: ReturnType<typeof createReportCollector> | undefined
const signal = clientSignal(res)
// the client may leave while waiting for a slot: the signal drops the render
await inspectSemaphore.run(async () => {
// the client may have left while waiting for a slot, no need to render for nobody
if (req.socket.destroyed || res.destroyed) return
await pageUtils.withPage(
target,
lang,
Expand All @@ -260,22 +281,25 @@ router.get('/inspect', async (req, res) => {
`Failed to inspect page "${target}" before timeout`,
async ({ page, ready }) => {
const report = await collector!.finish(ready)
let buffer
// the base64 string of the browser as is: decoding it to bytes to encode it back to base64
// would only copy megabytes on the main thread
let data
if (fullPage) {
const height = await page.evaluate(() => document.documentElement.scrollHeight)
buffer = await page.screenshot({ captureBeyondViewport: true, clip: { x: 0, y: 0, width: viewport.width, height: Math.min(height, maxFullPageHeight) } })
data = await page.screenshot({ encoding: 'base64', captureBeyondViewport: true, optimizeForSpeed: true, clip: { x: 0, y: 0, width: viewport.width, height: Math.min(height, maxFullPageHeight) } })
} else {
buffer = await page.screenshot()
data = await page.screenshot({ encoding: 'base64' })
}
timer.type = 'inspect'
res.json({ image: { data: Buffer.from(buffer).toString('base64'), mediaType: 'image/png' }, text: reportText(report), report })
res.json({ image: { data, mediaType: 'image/png' }, text: reportText(report), report })
},
{
onPage: page => { collector = createReportCollector(page, target) },
declaredErrorUrl,
onDeclaredError: body => collector?.declareError(body)
}
},
signal
)
})
}, signal).catch(err => { throw pageUtils.slotError(err) })
timer.finish()
})
2 changes: 2 additions & 0 deletions api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createHttpTerminator } from 'http-terminator'
import app from './app.ts'
import * as pageUtils from './utils/page.ts'
import * as gifsicle from './utils/gifsicle.ts'
import * as animation from './utils/animation.ts'
import config from '#config'

const server = createServer(app)
Expand Down Expand Up @@ -38,4 +39,5 @@ export const stop = async () => {
await httpTerminator.terminate()
if (config.observer.active) await stopObserver()
await pageUtils.stop()
await animation.stop()
}
38 changes: 38 additions & 0 deletions api/threads/gif-encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Runs in a piscina worker thread: decodes the png frames of an animated capture and encodes them
// as a gif. Both are pure JS and CPU heavy (NeuQuant quantization), on the main thread they
// blocked every other request of the service for seconds (cf benchmark/FINDINGS.md).
import { promisify } from 'node:util'
import GifEncoder from 'gif-encoder'
import getPixelsCb from 'get-pixels'
import { move } from 'piscina'

const getPixels = promisify(getPixelsCb)

export type GifEncodeTask = { width: number, height: number, frameRate: number, frames: Uint8Array[] }

export default async ({ width, height, frameRate, frames }: GifEncodeTask) => {
const gif = new GifEncoder(width, height, { highWaterMark: 64 * 1024 * 1024 })
const chunks: Buffer[] = []
gif.on('data', (chunk: Buffer) => chunks.push(chunk))
// finish() pushes the end of the stream, the last chunks are emitted asynchronously
const ended = new Promise<void>(resolve => gif.once('end', () => resolve()))
gif.setFrameRate(frameRate)
gif.writeHeader()
for (const frame of frames) {
// get-pixels switches on Buffer.isBuffer to tell raw image data from a file path
const pixels = await getPixels(Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength), 'image/png')
gif.addFrame(pixels.data)
}
gif.finish()
await ended
// the chunks are joined in an ArrayBuffer of our own, then transferred to the main thread, not
// copied. Buffer.concat would not do: below half of Buffer.poolSize it returns a slice of the
// shared Buffer pool, which cannot be transferred (DataCloneError)
const result = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0))
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return move(result)
}
59 changes: 33 additions & 26 deletions api/utils/animation.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import { file } from 'tmp-promise'
import path from 'node:path'
import { Piscina } from 'piscina'
import config from '#config'
import fs from 'node:fs'
import { promisify } from 'node:util'
import { pipeline } from 'node:stream/promises'
import GifEncoder from 'gif-encoder'
import getPixelsCb from 'get-pixels'
import { optimizeGif } from './gifsicle.ts'
import debugModule from 'debug'
import log from '@data-fair/lib-node/log.js'
import { type Page } from 'puppeteer'
import type { GifEncodeTask } from '../threads/gif-encode.ts'

const debug = debugModule('capture')

const getPixels = promisify(getPixelsCb)
// the gif encoding is CPU heavy pure JS, it runs in worker threads so that it does not block the
// service. maxThreads also bounds the number of gifs encoded at the same time
export const gifEncodePiscina = new Piscina<GifEncodeTask, Uint8Array>({
filename: path.resolve(import.meta.dirname, '../threads/gif-encode.ts'),
minThreads: 0,
idleTimeout: 10 * 60 * 1000,
maxThreads: config.gifThreads
})
// a pool emits 'error' when a thread dies with no task in flight; without a listener it crashes the process
gifEncodePiscina.on('error', (err) => log.error('gif encoding worker error', { code: 'piscina-gif-encode', err }))

// a typed array alone in its ArrayBuffer, so that the ArrayBuffer can be transferred to a worker.
// puppeteer returns Buffers decoded from base64, the small ones are slices of the shared Buffer
// pool (not transferable, and other buffers live in it): those are copied, the others kept
const ownBuffer = (frame: Uint8Array) => {
if (frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength && !(frame.buffer instanceof SharedArrayBuffer)) return frame
return new Uint8Array(frame)
}

export const capture = async (target: string, page: Page, width: number, height: number) => {
let stopped = false
const gif = new GifEncoder(width, height)
gif.setFrameRate(15) // 15fps seams like a good compromise for a gif
gif.writeHeader()
const { path, cleanup } = await file({ postfix: '.gif' })
const pipelinePromise = pipeline(gif, fs.createWriteStream(path))
let i = 0
while (!stopped && i < config.maxAnimationFrames) {
i++
if (i % 15 === 0) debug(`[${target}] ${i} frames taken`)
const frames: Uint8Array[] = []
while (!stopped && frames.length < config.maxAnimationFrames) {
if (frames.length % 15 === 0) debug(`[${target}] ${frames.length} frames taken`)
stopped = await page.evaluate(() => {
// @ts-ignore
return window.animateCaptureFrame()
Expand All @@ -35,16 +44,14 @@ export const capture = async (target: string, page: Page, width: number, height:
new Promise(resolve => setTimeout(resolve, config.screenshotTimeout))
])
if (!buffer) throw new Error(`Failed to capture animation frame of page "${target}" before timeout`)
// puppeteer returns a plain Uint8Array, but get-pixels switches on Buffer.isBuffer to tell
// raw image data from a file path, and would treat the frame as a filename otherwise
const frame = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const pixels = await getPixels(frame, 'image/png')
gif.addFrame(pixels.data)
frames.push(ownBuffer(buffer))
}
gif.finish()
await pipelinePromise
debug(`[${target}] ${frames.length} frames taken, encode the gif`)
// 15fps seams like a good compromise for a gif
// the frames are transferred to the worker, not copied: they are not usable here afterwards
const raw = await gifEncodePiscina.run({ width, height, frameRate: 15, frames }, { transferList: frames.map(f => f.buffer as ArrayBuffer) })
debug(`[${target}] gif screenshot is taken`)
const rawBuffer = await fs.promises.readFile(path)
cleanup()
return await optimizeGif(rawBuffer, 2)
return await optimizeGif(Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength), 2)
}

export const stop = () => gifEncodePiscina.destroy()
14 changes: 7 additions & 7 deletions api/utils/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,27 @@ export const tasks = new client.Histogram({
})
export const contextsCleanups = new client.Counter({
name: 'df_capture_contexts_cleanups',
help: 'Number of times a browser context was cleaned up for later reuse'
help: 'Number of browser contexts closed after their capture (one fresh context per capture)'
})

export const acquireContextPending = new client.Gauge({
name: 'df_capture_acquire_context_pending',
help: 'Number of tasks waiting to acquire a browser context',
help: 'Number of captures waiting for a slot',
async collect () {
this.set(pageUtils.contextPool && pageUtils.contextPool().pending)
this.set(pageUtils.captureSlots.pending())
}
})
export const acquireContextMax = new client.Gauge({
name: 'df_capture_acquire_context_max',
help: 'Max number of browser contexts that can be borrowed by tasks',
help: 'Max number of captures running at the same time',
async collect () {
this.set(pageUtils.contextPool && pageUtils.contextPool().max)
this.set(pageUtils.captureSlots.max)
}
})
export const acquireContextBorrowed = new client.Gauge({
name: 'df_capture_acquire_context_borrowed',
help: 'Number of browser contexts currently borrowed by a task',
help: 'Number of captures running',
async collect () {
this.set(pageUtils.contextPool && pageUtils.contextPool().borrowed)
this.set(pageUtils.captureSlots.active())
}
})
Loading
Loading