├── .editorconfig ├── .gitignore ├── .travis.yml ├── README.md ├── index.d.ts ├── index.js ├── package.json └── test └── index.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | insert_final_newline=false 5 | indent_style=space 6 | indent_size=2 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .DS_Store 3 | .idea/ 4 | node_modules/ 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | dist: trusty 4 | sudo: false 5 | language: node_js 6 | node_js: 7 | - "7" 8 | addons: 9 | chrome: stable 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Npm Package](https://img.shields.io/npm/v/chrome-render.svg?style=flat-square)](https://www.npmjs.com/package/chrome-render) 2 | [![Build Status](https://img.shields.io/travis/gwuhaolin/chrome-render.svg?style=flat-square)](https://travis-ci.org/gwuhaolin/chrome-render) 3 | [![Npm Downloads](http://img.shields.io/npm/dm/chrome-render.svg?style=flat-square)](https://www.npmjs.com/package/chrome-render) 4 | [![Dependency Status](https://david-dm.org/gwuhaolin/chrome-render.svg?style=flat-square)](https://npmjs.org/package/chrome-render) 5 | 6 | # chrome-render 7 | High-performance and universal server render base on [Headless chrome](https://www.chromestatus.com/feature/5678767817097216), render any SPA(render data in browser) in server for [SEO](https://github.com/gwuhaolin/koa-seo) or [other optimizes](https://github.com/gwuhaolin/koa-chrome-render). 8 | 9 | ## Use 10 | 1. install it from npm by `npm i chrome-render` 11 | 12 | 2. new a `ChromeRender` then use it to `render` a web page, a `ChromeRender` means a chrome. 13 | ```js 14 | const ChromeRender = require('chrome-render'); 15 | // ChromeRender.new() return a Promise, you can use async function in this way: 16 | // const chromeRender = await ChromeRender.new(); 17 | ChromeRender.new({}).then(async(chromeRender)=>{ 18 | const htmlString = await chromeRender.render({ 19 | url: 'http://qq.com', 20 | }); 21 | }); 22 | ``` 23 | > A `chromeRender` instance can call `render` multi-times and concurrent for high frequency use case. 24 | > `chromeRender` will manage a tabs pool to `render` multi-pages concurrent. 25 | 26 | 3. After you don't need chromeRender anymore, you should call `await chromeRender.destroyRender()` to kill chrome add release all resource. 27 | 28 | see more demo in [unit test](test/index.test.js) 29 | 30 | ## API 31 | #### `ChromeRender.new()` method support options: 32 | - `maxTab`: `number` max tab chrome will open to render pages, default is no limit, `maxTab` used to avoid open to many tab lead to chrome crash. `ChromeRender` will create a tab poll to reuse tab for performance improve and resource reduce as open and close tab in chrome require time, like database connection poll. 33 | - `chromeRunnerOptions`: `object` same as chrome-runner's options, can config chrome's startup options, detail see [chrome-runner options](https://github.com/gwuhaolin/chrome-runner#options) 34 | 35 | #### `chromeRender.render()` method support options: 36 | - `url`: `string` is required, web page's URL 37 | - `cookies`: `object {cookieName:cookieValue}` is an option param. set HTTP cookies when request web page 38 | - `headers`: `object {headerName:headerValue}` is an option param. add HTTP headers when request web page 39 | - `useReady`: `boolean` whether use `window.isPageReady=1` to notify chrome-render page is ready. default is false chrome-render use `domContentEventFired` as page has ready. 40 | - `script`: `string` is an option param. inject script source to evaluate when page on load 41 | - `renderTimeout`: `number` in ms, `render()` will throw error if html string can't be resolved after `renderTimeout`, default is 5000ms. 42 | - `deviceMetricsOverride`: `object` overrides the values of device screen dimensions for responsive websites, detail use see [here](https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDeviceMetricsOverride) 43 | - `clearTab`: `boolean` if `true` after render chrome instance will navigate to `about:blank` to free resources. default is `true`. setting to `false` may increase page load speed when rendering the same website. 44 | 45 | > all request from chrome-render will take with a HTTP header `x-chrome-render:${version}` 46 | 47 | ## Friends 48 | - chrome-render dependent on [chrome-pool](https://github.com/gwuhaolin/chrome-pool) headless chrome tabs manage pool. 49 | - [chrome-runner](https://github.com/gwuhaolin/chrome-runner) run chrome with nodejs in code. 50 | - [koa-chrome-render](https://github.com/gwuhaolin/koa-chrome-render) chrome-render middleware for koa. 51 | - [koa-seo](https://github.com/gwuhaolin/koa-seo) SEO middleware for koa base on chrome-render substitute for [prerender](https://prerender.io). 52 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export default class ChromeRender { 2 | 3 | static new(params: { 4 | /** 5 | * max tab chrome will open to render pages, default is no limit, `maxTab` used to avoid open to many tab lead to chrome crash. 6 | */ 7 | maxTab?: number, 8 | }): Promise; 9 | 10 | /** 11 | * render page in chrome, and return page html string 12 | * @param params 13 | * @returns {Promise.} page html string 14 | */ 15 | render(params: { 16 | /** 17 | * is required, web page's URL 18 | */ 19 | url: string, 20 | /** 21 | * `object {cookieName:cookieValue}` set HTTP cookies when request web page 22 | */ 23 | cookies?: { 24 | [cookieName: string]: string, 25 | }, 26 | /** 27 | * `object {headerName:headerValue}` add HTTP headers when request web page 28 | */ 29 | headers?: { 30 | [headerName: string]: string, 31 | }, 32 | /** 33 | * `boolean` whether use `window.chromeRenderReady()` to notify chrome-render page has ready. default is false chrome-render use `domContentEventFired` as page has ready. 34 | */ 35 | useReady?: boolean, 36 | /** 37 | * inject script to evaluate when page on load 38 | */ 39 | script?: string, 40 | /** 41 | * `number` in ms, `render()` will throw error if html string can't be resolved after `renderTimeout`, default is 5000ms. 42 | */ 43 | renderTimeout?: number, 44 | /** 45 | * `object` Overrides the values of device screen dimensions, same as https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDeviceMetricsOverride 46 | */ 47 | deviceMetricsOverride?: object, 48 | /** 49 | * `boolean` if `true` after render chrome instance will navigate to `about:blank` to free resources. default is true. setting to `false` may increase page load speed when rendering the same website. 50 | */ 51 | clearTab?: boolean, 52 | }): Promise; 53 | 54 | /** 55 | * destroyPoll this chrome render, kill chrome, release all resource 56 | */ 57 | destroyRender(): Promise; 58 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const ChromePoll = require('chrome-pool'); 3 | const package_json = require('./package.json'); 4 | 5 | const ERR_REQUIRE_URL = new Error('url param is required', 1); 6 | const ERR_RENDER_TIMEOUT = new Error('chrome-render timeout', 2); 7 | const ERR_PAGE_LOAD_FAILED = new Error('page load failed', 3); 8 | 9 | /** 10 | * a ChromeRender will launch a chrome with some tabs to render web pages. 11 | * use #new() static method to make a ChromeRender, don't use new ChromeRender() 12 | * #new() is a async function, new ChromeRender is use able util await it to be completed 13 | */ 14 | class ChromeRender { 15 | 16 | /** 17 | * make a new ChromeRender 18 | * @param {object} params 19 | * { 20 | * maxTab: `number` max tab chrome will open to render pages, default is no limit, `maxTab` used to avoid open to many tab lead to chrome crash. 21 | * chromeRunnerOptions: `object` same as chrome-runner's options, see [https://github.com/gwuhaolin/chrome-runner#options](chrome-runner options) 22 | * } 23 | * @return {Promise.} 24 | */ 25 | static async new(params = {}) { 26 | const { maxTab, chromeRunnerOptions } = params; 27 | const chromeRender = new ChromeRender(); 28 | chromeRender.chromePoll = await ChromePoll.new({ 29 | maxTab, 30 | protocols: ['Page', 'DOM', 'Network', 'Runtime', 'Emulation'], 31 | chromeRunnerOptions 32 | }); 33 | return chromeRender; 34 | } 35 | 36 | /** 37 | * render page in chrome, and return page html string 38 | * @param params 39 | * { 40 | * url: `string` is required, web page's URL 41 | * cookies: `object {cookieName:cookieValue}` set HTTP cookies when request web page 42 | * headers: `object {headerName:headerValue}` add HTTP headers when request web page 43 | * useReady: `boolean` whether use `window.chromeRenderReady()` to notify chrome-render page has ready. default is false chrome-render use `domContentEventFired` as page has ready. 44 | * script: inject script to evaluate when page on load 45 | * renderTimeout: `number` in ms, `render()` will throw error if html string can't be resolved after `renderTimeout`, default is 5000ms. 46 | * deviceMetricsOverride: `object` Overrides the values of device screen dimensions, same as https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDeviceMetricsOverride 47 | * } 48 | * @returns {Promise.} page html string 49 | */ 50 | async render(params) { 51 | let client; 52 | return await new Promise(async (resolve, reject) => { 53 | let timer; 54 | let { url, cookies, headers = {}, useReady, script, renderTimeout = 5000, deviceMetricsOverride } = params; 55 | 56 | // params assert 57 | // page url's requires 58 | if (!url) { 59 | return reject(ERR_REQUIRE_URL); 60 | } 61 | 62 | // open a tab 63 | client = await this.chromePoll.require(); 64 | const { Page, DOM, Network, Emulation, Runtime } = client.protocol; 65 | 66 | // add timeout reject 67 | timer = setTimeout(() => { 68 | reject(ERR_RENDER_TIMEOUT); 69 | clearTimeout(timer); 70 | }, renderTimeout); 71 | 72 | // get and resolve page HTML string when ready 73 | const resolveHTML = async () => { 74 | try { 75 | const dom = await DOM.getDocument(); 76 | const ret = await DOM.getOuterHTML({ nodeId: dom.root.nodeId }); 77 | resolve(ret.outerHTML); 78 | } catch (err) { 79 | reject(err); 80 | } 81 | clearTimeout(timer); 82 | }; 83 | 84 | // inject cookies 85 | if (cookies && typeof cookies === 'object') { 86 | Object.keys(cookies).forEach((name) => { 87 | Network.setCookie({ 88 | url: url, 89 | name: name, 90 | value: cookies[name], 91 | }); 92 | }) 93 | } 94 | 95 | // detect page load failed error 96 | let firstRequestId; 97 | Network.requestWillBeSent((params) => { 98 | if (firstRequestId === undefined) { 99 | firstRequestId = params.requestId; 100 | } 101 | }); 102 | Network.loadingFailed((params) => { 103 | if (params.requestId === firstRequestId) { 104 | reject(ERR_PAGE_LOAD_FAILED); 105 | } 106 | }); 107 | 108 | // inject script to evaluate when page on load 109 | if (typeof script === 'string') { 110 | Page.addScriptToEvaluateOnLoad({ 111 | scriptSource: script, 112 | }); 113 | } 114 | 115 | // detect request from chrome-render 116 | Network.setExtraHTTPHeaders({ 117 | headers: Object.assign({ 118 | 'x-chrome-render': package_json.version 119 | }, headers), 120 | }); 121 | 122 | if (useReady === true) { 123 | Page.frameNavigated(() => { 124 | // define window.isPageReady to listen page ready event 125 | // wait for page ready event to resolveHTML 126 | if (useReady === true) { 127 | // Page.frameNavigated may be fired more than one times 128 | useReady = false; 129 | Runtime.evaluate({ 130 | awaitPromise: true, 131 | silent: true, 132 | expression: ` 133 | new Promise((fulfill) => { 134 | Object.defineProperty(window, 'isPageReady', { 135 | set: function(value) { document.dispatchEvent(new Event('_crPageRendered')) }, 136 | }); 137 | document.addEventListener('_crPageRendered', fulfill, { 138 | once: true 139 | }); 140 | })` 141 | }).then(resolveHTML) 142 | .catch(reject); 143 | } 144 | }); 145 | } else { 146 | Page.domContentEventFired(resolveHTML); 147 | } 148 | 149 | // override device metrics 150 | if (deviceMetricsOverride && typeof deviceMetricsOverride === 'object') { 151 | // https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDeviceMetricsOverride 152 | Emulation.setDeviceMetricsOverride(deviceMetricsOverride); 153 | } 154 | 155 | // to go page 156 | await Page.navigate({ 157 | url, 158 | referrer: headers['referrer'] 159 | }); 160 | }).then((html) => { 161 | this.chromePoll.release(client.tabId, params.clearTab); 162 | return Promise.resolve(html); 163 | }).catch((err) => { 164 | this.chromePoll.release(client.tabId, params.clearTab); 165 | return Promise.reject(err); 166 | }); 167 | } 168 | 169 | /** 170 | * destroyPoll this chrome render, kill chrome, release all resource 171 | * @returns {Promise.} 172 | */ 173 | async destroyRender() { 174 | await this.chromePoll.destroyPoll(); 175 | this.chromePoll = null; 176 | } 177 | } 178 | 179 | module.exports = ChromeRender; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-render", 3 | "version": "1.4.1", 4 | "description": "General server render base on chrome", 5 | "keywords": [ 6 | "headless", 7 | "chrome", 8 | "server", 9 | "render", 10 | "react", 11 | "vue" 12 | ], 13 | "author": "gwuhaolin", 14 | "engines": { 15 | "node": ">= 7.0.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:gwuhaolin/chrome-render.git" 20 | }, 21 | "main": "index.js", 22 | "scripts": { 23 | "test": "mocha test" 24 | }, 25 | "dependencies": { 26 | "chrome-pool": "^1.2.0" 27 | }, 28 | "devDependencies": { 29 | "koa": "^2.2.0", 30 | "koa-static": "^4.0.1", 31 | "mocha": "^3.4.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const assert = require('assert'); 3 | const ChromeRender = require('../index'); 4 | 5 | process.on('unhandledRejection', console.trace); 6 | 7 | describe('#ChromeRender', function () { 8 | this.timeout(10000); 9 | 10 | it('#render()', async function () { 11 | const chromeRender = await ChromeRender.new(); 12 | const html = await chromeRender.render({ 13 | url: 'https://gwuhaolin.github.io/redemo/', 14 | renderTimeout: 2000, 15 | }); 16 | console.log(html); 17 | await chromeRender.destroyRender(); 18 | }); 19 | 20 | it('#render() set cookies', async function () { 21 | const chromeRender = await ChromeRender.new(); 22 | const html = await chromeRender.render({ 23 | url: 'https://gwuhaolin.github.io/reflv/', 24 | cookies: { 25 | 'token': 'token value' 26 | }, 27 | }); 28 | console.log(html); 29 | await chromeRender.destroyRender(); 30 | }); 31 | 32 | it('#render() set referrer', async function () { 33 | const chromeRender = await ChromeRender.new(); 34 | const html = await chromeRender.render({ 35 | url: 'http://google.com', 36 | referrer: 'http://baidu.com' 37 | }); 38 | console.log(html); 39 | await chromeRender.destroyRender(); 40 | }); 41 | 42 | it('#render() should timeout', function (done) { 43 | this.timeout(2000); 44 | (async () => { 45 | const chromeRender = await ChromeRender.new(); 46 | try { 47 | const html = await chromeRender.render({ 48 | url: 'http://qq.com', 49 | useReady: true, 50 | renderTimeout: 1000, 51 | }); 52 | console.log(html); 53 | } catch (err) { 54 | assert.equal(err.message, 'chrome-render timeout'); 55 | done(); 56 | } 57 | await chromeRender.destroyRender(); 58 | })() 59 | }); 60 | 61 | it('#render() cant load', async function () { 62 | const chromeRender = await ChromeRender.new(); 63 | try { 64 | await chromeRender.render({ 65 | url: 'http://thispage.cantload.com', 66 | }); 67 | } catch (err) { 68 | assert.equal(err.message, 'page load failed'); 69 | return Promise.resolve(err); 70 | } 71 | await chromeRender.destroyRender(); 72 | }); 73 | 74 | it('#render() use ready', async function () { 75 | this.timeout(20000); 76 | const chromeRender = await ChromeRender.new(); 77 | const html = await chromeRender.render({ 78 | url: 'https://gwuhaolin.github.io/reflv/live.html', 79 | useReady: true, 80 | renderTimeout: 5000 81 | }); 82 | console.log(html); 83 | assert(html.indexOf('data-reactroot=') > 0, 'should resolve after react render html out'); 84 | await chromeRender.destroyRender(); 85 | }); 86 | 87 | it('#render() clearTab=false', async function () { 88 | this.timeout(20000); 89 | const chromeRender = await ChromeRender.new({ 90 | maxTab: 2 91 | }); 92 | let task = []; 93 | for (let i = 0; i < 10; i++) { 94 | task.push(chromeRender.render({ 95 | url: `https://gwuhaolin.github.io/redemo/?${i}`, 96 | useReady: true, 97 | script: `setTimeout(function(){window.isPageReady = 1;}, 1000);`, 98 | renderTimeout: 5000, 99 | clearTab: false, 100 | })); 101 | } 102 | await Promise.all(task); 103 | await chromeRender.destroyRender(); 104 | }); 105 | 106 | it('#render() inject script', async function () { 107 | const chromeRender = await ChromeRender.new(); 108 | const html = await chromeRender.render({ 109 | url: 'https://bing.com', 110 | useReady: true, 111 | script: `setTimeout(function(){ window.isPageReady=1 }, 1000);`, 112 | }); 113 | console.log(html); 114 | await chromeRender.destroyRender(); 115 | }); 116 | 117 | it('#render() render multi pages concurrent', async function () { 118 | this.timeout(100000); 119 | const chromeRender = await ChromeRender.new(); 120 | const tasks = []; 121 | [ 122 | 'https://github.com', 123 | 'https://www.alibaba.com', 124 | 'https://bing.com', 125 | ].forEach(url => { 126 | tasks.push(chromeRender.render({ 127 | url, 128 | renderTimeout: 100000, 129 | })); 130 | }); 131 | await Promise.all(tasks); 132 | await chromeRender.destroyRender(); 133 | }); 134 | 135 | it('#render() use mobile to visit by set deviceMetricsOverride', async function () { 136 | const chromeRender = await ChromeRender.new(); 137 | 138 | // mobile version 139 | let html = await chromeRender.render({ 140 | url: 'https://www.google.com', 141 | headers: { 142 | 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A300 Safari/602.1', 143 | }, 144 | deviceMetricsOverride: { 145 | width: 100, 146 | height: 200, 147 | deviceScaleFactor: 0, 148 | fitWindow: true, 149 | mobile: true, 150 | } 151 | }); 152 | assert(html.indexOf('apple-touch-icon') > 0, `visit mobile version should has apple-touch-icon, html:${html}`); 153 | 154 | // desktop version 155 | html = await chromeRender.render({ 156 | url: 'https://www.google.com', 157 | }); 158 | assert(html.indexOf('apple-touch-icon') === -1, `default is visit desktop version should not has apple-touch-icon, html:${html}`); 159 | 160 | await chromeRender.destroyRender(); 161 | }); 162 | 163 | }); --------------------------------------------------------------------------------