├── .gitignore ├── .prettierrc ├── package.json ├── Dockerfile ├── LICENSE ├── CHANGES.md ├── README.md └── src └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screenie-server", 3 | "version": "4.0.0", 4 | "description": "A screenshot HTTP service using Puppeteer", 5 | "author": "Frode Danielsen ", 6 | "keywords": [ 7 | "screenshot", 8 | "puppeteer", 9 | "koa" 10 | ], 11 | "main": "src/server.js", 12 | "bin": { 13 | "screenie": "src/server.js" 14 | }, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "license": "MIT", 19 | "engines": { 20 | "node": ">=10.18.1" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/eliksir/screenie-server.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/eliksir/screenie-server/issues" 28 | }, 29 | "dependencies": { 30 | "koa": "2.13.1", 31 | "puppeteer": "8.0.0", 32 | "puppeteer-pool": "eliksir/puppeteer-pool#v1.3.1", 33 | "winston": "3.3.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine3.12 2 | 3 | ARG TARGETPLATFORM 4 | 5 | ENV SCREENIE_VERSION=4.0.0 6 | ENV SCREENIE_CHROMIUM_ARGS=--no-sandbox 7 | ENV SCREENIE_CHROMIUM_EXEC=/usr/lib/chromium/chrome 8 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 9 | 10 | RUN mkdir -p /usr/src/app 11 | WORKDIR /usr/src/app 12 | 13 | # Installs latest Chromium package 14 | RUN apk update && apk upgrade && \ 15 | apk add --no-cache \ 16 | chromium \ 17 | nss \ 18 | freetype \ 19 | harfbuzz \ 20 | ttf-freefont \ 21 | font-noto-cjk \ 22 | git 23 | 24 | RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ 25 | wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64; else \ 26 | wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_aarch64; fi \ 27 | && chmod +x /usr/local/bin/dumb-init 28 | 29 | ENTRYPOINT ["dumb-init"] 30 | 31 | RUN npm install -g screenie-server@${SCREENIE_VERSION} --unsafe-perm 32 | 33 | EXPOSE 3000 34 | 35 | CMD /usr/local/bin/screenie 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2017 Screenie Server contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 4.0.0 / 26-02-2021 2 | 3 | - _BREAKING_: Minimum Node 10.18.1 4 | - Update to Puppeteer 8.0.0 5 | - Update Koa and Winston to latest minor release 6 | - Update Docker image to Node 14 and alpine 3.12 7 | - Multi arch Docker image with arm64 support 8 | 9 | # 3.0.0 / 08-01-2020 10 | 11 | - No new changes from 3.0.0-beta.2 12 | 13 | # 3.0.0-beta.2 / 05-11-2019 14 | 15 | - _BREAKING_: Don't render screenshot for URLs that respond with error status. 16 | 17 | # 3.0.0-beta.1 / 04-11-2019 18 | 19 | - Support waiting for `document.fonts.ready` event. 20 | - Make `SCREENIE_SCREENSHOT_DELAY` environment optional. 21 | 22 | # 3.0.0-beta / 31-10-2019 23 | 24 | - Use Alpine as base Docker image 25 | - Update puppeteer to 1.19.0 26 | - Update Koa to 2.11.0 27 | - Update Winston to 3.2.1 28 | 29 | # 2.0.0 / 30-01-2018 30 | 31 | This is a major release which might require some more manual setup of 32 | Chromium to make use of. Don't upgrade to this before you've _checked the 33 | requirements_ of Chromium/Puppeteer, particularly when it comes to sandbox 34 | support in your kernel for security. 35 | 36 | - Switches to Chromium through Puppeteer over PhantomJS 37 | - Support a custom delay after page load before screenshot is generated 38 | - Support flag for enabling file protocol URLs 39 | 40 | # 1.2.0 / 16-10-2017 41 | 42 | - Add basic logging functionality 43 | - Handle SIGTERM gracefully, draining the pool 44 | - Added Dockerfile with CA certificate updates 45 | 46 | # 1.1.0 / 17-03-2017 47 | 48 | - Add PDF output support 49 | - Add support to customize the output format with a `format` request parameter 50 | 51 | # 1.0.0 / 06-02-2017 52 | 53 | - Initial public release 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # screenie-server 2 | 3 | HTTP screenshot service based on [Puppeteer](https://github.com/GoogleChrome/puppeteer). 4 | 5 | Creates a HTTP server using [Koa](https://github.com/koajs/koa), by default on 6 | port 3000. It renders pages and creates screenshots of them on request. 7 | 8 | ## Installation / Usage 9 | 10 | You can install from npm and run the server manually: 11 | 12 | ```bash 13 | npm install screenie-server 14 | ./node_modules/.bin/screenie-server 15 | ``` 16 | 17 | Alternatively, we provide a Docker image (built from the 18 | [Dockerfile](Dockerfile)) at [eliksir/screenie-server](https://hub.docker.com/r/eliksir/screenie-server/). 19 | This container is not running in sandbox mode because the Docker image doesn't 20 | support user namespaces. 21 | 22 | ## Configuration 23 | 24 | Then request a screenshot of an URL using the `url` query parameter: 25 | `http://localhost:3000/?url=http://google.com/&format=jpeg` 26 | 27 | The size of the screenshot can be customized through the `width` and `height` 28 | query parameters, but will always be constrained within 2048x2048. The default 29 | size used when the parameters are missing can be customized by environment 30 | variables: 31 | 32 | * `SCREENIE_WIDTH`: Default width, as integer, in pixels (default `1024`). 33 | * `SCREENIE_HEIGHT`: Default height, as integer, in pixels (default `768`). 34 | 35 | The `format` query parameter can be used to request a specific format of the 36 | screenshot. The supported formats are PNG, JPEG and even PDF. You can 37 | also set the default format through an environment variable: 38 | 39 | * `SCREENIE_DEFAULT_FORMAT`: Default format (default `jpeg`). 40 | 41 | The Puppeteer pool can also be customized with environment variables: 42 | 43 | * `SCREENIE_POOL_MIN`: Minimum number of Puppeteer instances (default `2`). 44 | * `SCREENIE_POOL_MAX`: Maximum number of Puppeteer instances (default `10`). 45 | 46 | To control the level of logging that will be performed, customize the 47 | `SCREENIE_LOG_LEVEL` environment variable. Supported values are `error`, 48 | `warn`, `info`, `verbose`, `debug`, and `silly`, though only `info` and 49 | `verbose` are currently in use. 50 | 51 | * `SCREENIE_LOG_LEVEL`: Logging level (default `info`). 52 | 53 | To open up file scheme in URL parameter: 54 | 55 | * `SCREENIE_ALLOW_FILE_SCHEME`: true (default `false`). 56 | 57 | Delay from the `load` event until the screenshot is taken. This can solve 58 | issues with rendering (i.e. rendering webfonts) not being complete before the 59 | screenshot. 60 | 61 | * `SCREENIE_SCREENSHOT_DELAY`: Time in milliseconds (default `50`). 62 | 63 | And lastly, of course the HTTP port can be customized: 64 | 65 | * `SCREENIE_PORT`: HTTP port (default `3000`). 66 | 67 | ## Contributing 68 | 69 | We are open to contributions or suggestions. File issues or suggestions on the 70 | [GitHub issues page](https://github.com/eliksir/screenie-server/issues), and 71 | please do submit a pull request if you have the time to implement an 72 | improvement or bugfix. 73 | 74 | ## License 75 | 76 | Published under the MIT license. 77 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const Koa = require('koa'); 4 | const winston = require('winston'); 5 | const createPuppeteerPool = require('puppeteer-pool').default; 6 | const { combine, timestamp, printf } = winston.format; 7 | 8 | const loggerFormat = printf(({ message, timestamp }) => { 9 | return `[${timestamp}] ${message}`; 10 | }); 11 | 12 | const logger = winston.createLogger({ 13 | level: process.env.SCREENIE_LOG_LEVEL || 'info', 14 | format: combine(timestamp(), loggerFormat), 15 | transports: [ 16 | new winston.transports.Console({ 17 | timestamp: () => new Date().toISOString(), 18 | }), 19 | ], 20 | }); 21 | 22 | logger.log('verbose', 'Setting up defaults from environment'); 23 | const chromiumArgs = process.env.SCREENIE_CHROMIUM_ARGS 24 | ? { args: process.env.SCREENIE_CHROMIUM_ARGS.split(' ') } 25 | : {}; 26 | const chromiumExec = process.env.SCREENIE_CHROMIUM_EXEC 27 | ? { executablePath: process.env.SCREENIE_CHROMIUM_EXEC } 28 | : {}; 29 | const defaultFormat = process.env.SCREENIE_DEFAULT_FORMAT || 'jpeg'; 30 | const imageSize = { 31 | width: process.env.SCREENIE_WIDTH || 1024, 32 | height: process.env.SCREENIE_HEIGHT || 768, 33 | }; 34 | const serverPort = process.env.SCREENIE_PORT || 3000; 35 | const supportedFormats = ['jpg', 'jpeg', 'pdf', 'png']; 36 | const allowFileScheme = process.env.SCREENIE_ALLOW_FILE_SCHEME || false; 37 | 38 | const app = new Koa(); 39 | logger.log('verbose', 'Created KOA server'); 40 | 41 | const pool = createPuppeteerPool({ 42 | min: process.env.SCREENIE_POOL_MIN || 2, 43 | max: process.env.SCREENIE_POOL_MAX || 10, 44 | puppeteerArgs: Object.assign({}, chromiumArgs, chromiumExec), 45 | }); 46 | 47 | const screenshotDelay = process.env.SCREENIE_SCREENSHOT_DELAY; 48 | 49 | logger.log('verbose', 'Created Puppeteer pool'); 50 | 51 | /* 52 | * Clean up the Puppeteer pool before exiting when receiving a termination 53 | * signal. Exit with status code 143 (128 + SIGTERM's signal number, 15). 54 | */ 55 | process.on('SIGTERM', () => { 56 | logger.log('info', 'Received SIGTERM, exiting...'); 57 | pool 58 | .drain() 59 | .then(() => pool.clear()) 60 | .then(() => process.exit(143)); 61 | }); 62 | 63 | /** 64 | * Set up a Puppeteer instance for a page and configure viewport size. 65 | */ 66 | app.use(async (ctx, next) => { 67 | const { width, height } = ctx.request.query; 68 | const size = { 69 | width: Math.min(2048, parseInt(width, 10) || imageSize.width), 70 | height: Math.min(2048, parseInt(height, 10) || imageSize.height), 71 | }; 72 | let pageError; 73 | 74 | logger.log( 75 | 'verbose', 76 | `Instantiating Page with size ${size.width}x${size.height}` 77 | ); 78 | 79 | await pool.use(instance => { 80 | const pid = instance.process().pid; 81 | logger.log('verbose', `Using browser instance with PID ${pid}`); 82 | return instance 83 | .newPage() 84 | .then(page => { 85 | logger.log('verbose', 'Set page instance on state'); 86 | ctx.state.page = page; 87 | }) 88 | .then(() => { 89 | logger.log('verbose', 'Set viewport for page'); 90 | return ctx.state.page.setViewport(size); 91 | }) 92 | .catch(error => { 93 | pageError = error; 94 | logger.log('verbose', `Invalidating instance with PID ${pid}`); 95 | pool.invalidate(instance); 96 | }); 97 | }); 98 | 99 | if (pageError) { 100 | ctx.throw(400, `Could not open a page: ${pageError.message}`); 101 | } 102 | 103 | await next(); 104 | }); 105 | 106 | /** 107 | * Attempt to load given URL in the Puppeteer page. 108 | * 109 | * Throws 400 Bad Request if no URL is provided, and 404 Not Found if 110 | * Puppeteer could not load the URL. 111 | */ 112 | app.use(async (ctx, next) => { 113 | const { page } = ctx.state; 114 | const { url } = ctx.request.query; 115 | 116 | let errorStatus = null; 117 | 118 | if (!url) { 119 | ctx.throw(400, 'No url request parameter supplied.'); 120 | } 121 | 122 | if (url.indexOf('file://') >= 0 && !allowFileScheme) { 123 | ctx.throw(403); 124 | } 125 | 126 | logger.log('verbose', `Attempting to load ${url}`); 127 | 128 | try { 129 | const response = await page.goto(url); 130 | const status = response.status(); 131 | 132 | if (status < 200 || status > 299) { 133 | errorStatus = status; 134 | throw new Error('Non-OK server response'); 135 | } 136 | 137 | await page.evaluateHandle('document.fonts.ready'); 138 | 139 | if (screenshotDelay) { 140 | await new Promise(resolve => setTimeout(resolve, screenshotDelay)); 141 | } 142 | } catch (error) { 143 | // Sets a catch-all error status for cases where `page.goto` throws 144 | if (!errorStatus) { 145 | errorStatus = 500; 146 | } 147 | } 148 | 149 | if (errorStatus) { 150 | ctx.throw(errorStatus); 151 | } 152 | 153 | await next(); 154 | }); 155 | 156 | /** 157 | * Determine the format of the output based on the `format` query parameter. 158 | * 159 | * The format must be among the formats supported by Puppeteer, else 400 160 | * Bad Request is thrown. If no format is provided, the default is used. 161 | */ 162 | app.use(async (ctx, next) => { 163 | const { format = defaultFormat } = ctx.request.query; 164 | 165 | if (supportedFormats.indexOf(format.toLowerCase()) === -1) { 166 | ctx.throw(400, `Format ${format} not supported.`); 167 | } 168 | 169 | ctx.type = ctx.state.format = format; 170 | 171 | await next(); 172 | }); 173 | 174 | /** 175 | * Generate a screenshot of the loaded page. 176 | * 177 | * If successful the screenshot is sent as the response. 178 | */ 179 | app.use(async (ctx, next) => { 180 | const { url, fullPage } = ctx.request.query; 181 | const { format, page } = ctx.state; 182 | const { width, height } = page.viewport(); 183 | let renderError; 184 | 185 | logger.log('info', `Rendering screenshot of ${url} to ${format}`); 186 | 187 | if (format === 'pdf') { 188 | await page 189 | .pdf({ 190 | format: 'A4', 191 | margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }, 192 | }) 193 | .then(response => (ctx.body = response)) 194 | .catch(error => (renderError = error)); 195 | } else { 196 | let clipInfo = 197 | fullPage === '1' 198 | ? { fullPage: true } 199 | : { clip: { x: 0, y: 0, width: width, height: height } }; 200 | await page 201 | .screenshot( 202 | Object.assign( 203 | { 204 | type: format === 'jpg' ? 'jpeg' : format, 205 | omitBackground: true, 206 | }, 207 | clipInfo 208 | ) 209 | ) 210 | .then(response => (ctx.body = response)) 211 | .catch(error => (renderError = error)); 212 | } 213 | 214 | if (renderError) { 215 | ctx.throw(400, `Could not render page: ${renderError.message}`); 216 | } 217 | 218 | await page.close(); 219 | 220 | await next(); 221 | }); 222 | 223 | /** 224 | * Error handler to make sure page is getting closed. 225 | */ 226 | app.on('error', (error, context) => { 227 | const { page } = context.state; 228 | 229 | if (page) { 230 | page.close(); 231 | } 232 | 233 | logger.log('error', error.message); 234 | }); 235 | 236 | app.listen(serverPort); 237 | logger.log('info', `Screenie server started on port ${serverPort}`); 238 | --------------------------------------------------------------------------------