Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
kimmobrunfeldt committed Sep 28, 2017
0 parents commit 89e1b32
Show file tree
Hide file tree
Showing 20 changed files with 576 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .env.sample
@@ -0,0 +1,15 @@
#!/bin/bash

# Guide:
#
# 1. Copy this file to .env
#
# cp .env-sample .env
#
# 2. Fill the blanks

export NODE_ENV=development
export PORT=9000
export ALLOW_HTTP=true

echo "Environment variables set!"
17 changes: 17 additions & 0 deletions .eslintrc
@@ -0,0 +1,17 @@
{
"env": {
"browser": true,
"amd": true,
"node": true,
"es6": true
},
"extends": "airbnb-base",
"rules": {
"no-implicit-coercion": "error",
"no-process-env": "error",
"no-path-concat": "error",
"import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
"no-use-before-define": ["error", { "functions": false }],
"no-underscore-dangle": "off"
}
}
50 changes: 50 additions & 0 deletions .gitignore
@@ -0,0 +1,50 @@
.DS_Store
.idea

# Logs
logs
*.log
npm-debug.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules
jspm_packages

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity
1 change: 1 addition & 0 deletions Procfile
@@ -0,0 +1 @@
web: NODE_ENV=production node src/index.js
33 changes: 33 additions & 0 deletions README.md
@@ -0,0 +1,33 @@
# URL to PDF

> Web page PDF rendering done right. Packaged to an easy API.
A simple API which converts a given URL to a PDF. **Why is it "done right"?**

* Rendered with Headless Chrome, using [Puppeteer](https://github.com/GoogleChrome/puppeteer)
* Sensible defaults
* Easy deployment to Heroku. I love Lambda but.. Deploy to Heroku button.


**Requires Node 8+ (async, await).**

## Get started

* `cp .env.sample .env`
* Fill in the blanks in `.env`
* `source .env` or `bash .env`

Or use [autoenv](https://github.com/kennethreitz/autoenv).

* `npm install`
* `npm start` Start express server locally
* Server runs at http://localhost:9000 or what `$PORT` env defines


## Techstack

* Node 8+ (async, await), written in ES7
* [Express.js](https://expressjs.com/) app with a nice internal architecture, based on [these conventions](https://github.com/kimmobrunfeldt/express-example).
* Hapi-style Joi validation with [express-validation](https://github.com/andrewkeig/express-validation)
* Heroku + [Puppeteer buildpack](https://github.com/jontewks/puppeteer-heroku-buildpack)
* [Puppeteer](https://github.com/GoogleChrome/puppeteer) to control Chrome
27 changes: 27 additions & 0 deletions app.json
@@ -0,0 +1,27 @@
{
"name": "url-to-pdf-api",
"description": "Web page PDF rendering done right. Packaged to an easy API.",
"keywords": [
"pdf",
"html",
"html to pdf",
"html 2 pdf",
"render"
],
"website": "https://github.com/kimmobrunfeldt/url-to-pdf-api",
"repository": "https://github.com/kimmobrunfeldt/url-to-pdf-api",
"env": {
"ALLOW_HTTP": {
"description": "When set to \"true\", unsecure requests are allowed.",
"value": "false"
}
},
"buildpacks": [
{
"url": "https://github.com/jontewks/puppeteer-heroku-buildpack"
},
{
"url": "http://github.com/heroku/heroku-buildpack-nodejs.git"
}
]
}
33 changes: 33 additions & 0 deletions package.json
@@ -0,0 +1,33 @@
{
"name": "url-to-pdf-api",
"version": "1.0.0",
"description": "Web page PDF rendering done right. Packaged to an easy API.",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/kimmobrunfeldt/url-to-pdf-api.git"
},
"author": "Kimmo Brunfeldt",
"license": "MIT",
"bugs": {
"url": "https://github.com/kimmobrunfeldt/url-to-pdf-api/issues"
},
"homepage": "https://github.com/kimmobrunfeldt/url-to-pdf-api#readme",
"dependencies": {
"bluebird": "^3.5.0",
"body-parser": "^1.18.2",
"compression": "^1.7.1",
"cors": "^2.8.4",
"express": "^4.15.5",
"express-validation": "^1.0.2",
"joi": "^11.1.1",
"lodash": "^4.17.4",
"morgan": "^1.9.0",
"puppeteer": "^0.11.0",
"server-destroy": "^1.0.1",
"winston": "^2.3.1"
}
}
47 changes: 47 additions & 0 deletions src/app.js
@@ -0,0 +1,47 @@
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
const compression = require('compression');
const cors = require('cors');
const logger = require('./util/logger')(__filename);
const errorResponder = require('./middleware/error-responder');
const ipLogger = require('./middleware/ip-logger');
const errorLogger = require('./middleware/error-logger');
const requireHttps = require('./middleware/require-https');
const createRouter = require('./router');
const config = require('./config');

function createApp() {
const app = express();
// App is served behind Heroku's router.
// This is needed to be able to use req.ip or req.secure
app.enable('trust proxy', 1);
app.disable('x-powered-by');

if (config.NODE_ENV !== 'production') {
app.use(morgan('dev'));
}

const corsOpts = {
origin: config.CORS_ORIGIN,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'],
};
logger.info('Using CORS options:', corsOpts);
app.use(cors(corsOpts));
app.use(bodyParser.json({ limit: '1mb' }));
app.use(compression({
// Compress everything over 10 bytes
threshold: 10,
}));

// Initialize routes
const router = createRouter();
app.use('/', router);

app.use(errorLogger());
app.use(errorResponder());

return app;
}

module.exports = createApp;
11 changes: 11 additions & 0 deletions src/config.js
@@ -0,0 +1,11 @@
/* eslint-disable no-process-env */
const requireEnvs = require('./util/require-envs');

// Env vars should be casted to correct types
const config = {
PORT: Number(process.env.PORT) || 9000,
NODE_ENV: process.env.NODE_ENV,
LOG_LEVEL: process.env.LOG_LEVEL,
};

module.exports = config;
37 changes: 37 additions & 0 deletions src/core/pdf-core.js
@@ -0,0 +1,37 @@
const fs = require('fs');
const puppeteer = require('puppeteer');
const BPromise = require('bluerbird');
const _ = require('lodash');

BPromise.promisifyAll(fs);

async function render(_opts = {}) {
const opts = _.merge({
viewport: {
width: 1200,
height: 800,
},
goto: {
waitUntil: 'networkidle',
},
pdf: {
format: 'A4',
}
}, _opts);

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport(opts.viewport)
await page.goto(params.url, opts.goto);
await page.pdf(_.merge({}, opts.pdf, {
path: 'page.pdf',
}));

await browser.close();

return fs.readFileAsync('page.pdf', { encoding: null });
}

module.exports = {
render,
};
14 changes: 14 additions & 0 deletions src/http/pdf-http.js
@@ -0,0 +1,14 @@
const ex = require('../util/express');
const pdfCore = require('../core/pdf-core');

const getRender = ex.createJsonRoute((req) => {
const params = {
url: req.query.url,
};

return pdfCore.render(params);
});

module.exports = {
getRender,
};
39 changes: 39 additions & 0 deletions src/index.js
@@ -0,0 +1,39 @@
const createApp = require('./app');
const enableDestroy = require('server-destroy');
const BPromise = require('bluebird');
const logger = require('./util/logger')(__filename);
const config = require('./config');

BPromise.config({
warnings: config.NODE_ENV !== 'production',
longStackTraces: true,
});

const app = createApp();
const server = app.listen(config.PORT, () => {
logger.info(
'Express server listening on http://localhost:%d/ in %s mode',
config.PORT,
app.get('env')
);
});
enableDestroy(server);

function closeServer(signal) {
logger.info(`${signal} received`);
logger.info('Closing http.Server ..');
server.destroy();
}

// Handle signals gracefully. Heroku will send SIGTERM before idle.
process.on('SIGTERM', closeServer.bind(this, 'SIGTERM'));
process.on('SIGINT', closeServer.bind(this, 'SIGINT(Ctrl-C)'));

server.on('close', () => {
logger.info('Server closed');
process.emit('cleanup');

logger.info('Giving 100ms time to cleanup..');
// Give a small time frame to clean up
setTimeout(process.exit, 100);
});
59 changes: 59 additions & 0 deletions src/middleware/error-logger.js
@@ -0,0 +1,59 @@
const _ = require('lodash');
const logger = require('../util/logger')(__filename);

function createErrorLogger(opts) {
opts = _.merge({
logRequest: status => {
return status >= 400 && status !== 404 && status !== 503;
},
logStackTrace: status => {
return status >= 500 && status !== 503;
}
}, opts);

return function errorHandler(err, req, res, next) {
const status = err.status ? err.status : 500;
const logLevel = getLogLevel(status);
const log = logger[logLevel];

if (opts.logRequest(status)) {
logRequestDetails(logLevel, req, status);
}

if (opts.logStackTrace(status)) {
log(err, err.stack);
}
else {
log(err.toString());
}

next(err);
};
}

function getLogLevel(status) {
return status >= 500 ? 'error' : 'warn';
}

function logRequestDetails(logLevel, req, status) {
logger[logLevel]('Request headers:', deepSupressLongStrings(req.headers));
logger[logLevel]('Request parameters:', deepSupressLongStrings(req.params));
logger.logEncrypted(logLevel, 'Request body:', req.body);
}

function deepSupressLongStrings(obj) {
let newObj = {};
_.each(obj, (val, key) => {
if (_.isString(val) && val.length > 100) {
newObj[key] = val.slice(0, 100) + '... [CONTENT SLICED]';
} else if (_.isPlainObject(val)) {
return deepSupressLongStrings(val);
} else {
newObj[key] = val;
}
});

return newObj;
}

module.exports = createErrorLogger;

0 comments on commit 89e1b32

Please sign in to comment.