├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── README.md ├── __tests__ ├── application.spec.ts ├── dynamic-render.spec.ts ├── engine.spec.ts ├── helpers.ts ├── hook.spec.ts ├── index.spec.ts ├── interceptor.spec.ts ├── page.spec.ts └── server.spec.ts ├── cycle.svg ├── demo ├── demo.ts ├── jpg_placeholder └── png_placeholder ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── application.ts ├── dynamic-render.ts ├── engine.ts ├── hook.ts ├── index.ts ├── interceptor.ts ├── page.ts ├── response-cache.ts ├── server.ts └── types.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | 4 | defaults: &defaults 5 | docker: 6 | - image: circleci/node:10 7 | 8 | 9 | orbs: 10 | codecov: codecov/codecov@1.0.4 11 | 12 | 13 | jobs: 14 | test: 15 | <<: *defaults 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | - run: npm i 22 | - save_cache: 23 | paths: 24 | - node_modules 25 | key: v1-dependencies-{{ checksum "package.json" }} 26 | - run: npm run test 27 | - store_artifacts: 28 | path: coverage 29 | - codecov/upload: 30 | file: coverage/*.json 31 | token: $CODECOV_TOKEN 32 | - run: npm run build 33 | - persist_to_workspace: 34 | root: . 35 | paths: 36 | - README.md 37 | - LICENSE 38 | - package.json 39 | - package-lock.json 40 | - .npmignore 41 | - dist 42 | deploy: 43 | <<: *defaults 44 | steps: 45 | - attach_workspace: 46 | at: . 47 | - run: 48 | name: List Workspace 49 | command: ls 50 | - run: 51 | name: Authenticate with registry 52 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc 53 | - run: 54 | name: Publish package 55 | command: npm publish 56 | 57 | workflows: 58 | version: 2 59 | test-deploy: 60 | jobs: 61 | - test: 62 | filters: 63 | tags: 64 | only: /^v.*/ 65 | - hold: 66 | type: approval 67 | requires: 68 | - test 69 | filters: 70 | branches: 71 | only: master 72 | - deploy: 73 | requires: 74 | - hold 75 | filters: 76 | branches: 77 | only: master 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | .idea 66 | dist 67 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | coverage 3 | demo 4 | src 5 | cycle.svg 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic Render 2 | Optimizes SEO by dynamically rendering javascript powered websites 3 | 4 | [![CircleCI](https://circleci.com/gh/Trendyol/dynamic-render.svg?style=svg)](https://circleci.com/gh/Trendyol/dynamic-render) [![codecov](https://codecov.io/gh/Trendyol/dynamic-render/branch/master/graph/badge.svg)](https://codecov.io/gh/Trendyol/dynamic-render) [![npm version](https://badge.fury.io/js/dynamic-render.svg)](https://www.npmjs.com/package/dynamic-render) 5 | 6 | ## Guide 7 | * [Install](#install) 8 | * [Getting Started](#getting-started) 9 | * [Render Cycle](#render-cycle) 10 | * [Interceptors](#interceptors) 11 | * [Hooks](#hooks) 12 | * [Page](#page) 13 | * [Application](#application) 14 | 15 | 16 | ## Install 17 | 18 | ```bash 19 | npm install dynamic-render 20 | ``` 21 | 22 | ## Getting Started 23 | For full demo please see [Demo Folder](./demo/demo.ts) 24 | 25 | 1. First create page configuration 26 | 2. Create an application and register pages 27 | 3. Start Dynamic Render 28 | ```js 29 | const dynamicRender = require('dynamic-render'); 30 | const examplePage = dynamicRender.page({ 31 | name: 'example-page', 32 | hooks: [], 33 | interceptors: [], 34 | matcher: '/example/:pageParam', 35 | followRedirects: false, // Dynamic render follow the requests redirects and respond directly to browser. Default "followRedirects" is true. 36 | }); 37 | 38 | dynamicRender.application('example-web', { 39 | pages: [examplePage], 40 | origin: 'https://example-site.com' 41 | }); 42 | 43 | dynamicRender 44 | .start() 45 | .then(port => { 46 | console.log(`Prerender listening on ${port}`); 47 | }); 48 | ``` 49 | 50 | *(Optional) You can pass configuration parameters for debugging purposes* 51 | ```js 52 | const config = { 53 | puppeteer: { 54 | headless: false, 55 | ignoreHTTPSErrors: true, 56 | devtools: true, 57 | }, 58 | port: 8080 59 | } 60 | 61 | dynamicRender 62 | .start(config) 63 | .then(port => { 64 | console.log(`Prerender listening on ${port}`); 65 | }); 66 | ``` 67 | 68 | Now you can send request to `http://localhost:8080/render/example-web/example/35235657`, dynamic render will respond with rendered content. 69 | 70 | 71 | ### Render Cycle 72 | ![Render Cycle](./cycle.svg) 73 | 74 | 75 | -------- 76 | 77 | ### Interceptors 78 | Interceptors are responsible for modifying or blocking http requests. For optimizing rendering performance you may want to mock assets. Best use case for interceptors is handling image requests. 79 | 80 | ```js 81 | const dynamicRender = require('dynamic-render'); 82 | const placeholderPng = fs.readFileSync(path.join(__dirname, './png_placeholder')); 83 | const imageInterceptor = dynamicRender.interceptor({ 84 | name: 'Image Interceptor', 85 | handler: (req, respond) => { 86 | const url = req.url(); 87 | if (url.endsWith('png')) { 88 | respond({ 89 | body: placeholderPng, 90 | contentType: 'image/png' 91 | }) 92 | } 93 | } 94 | }); 95 | ``` 96 | 97 | Interceptors can be shared across pages. To register an interceptor to a page you use interceptors property of it. 98 | ```js 99 | const examplePage = dynamicRender.page({ 100 | name: 'example-page', 101 | hooks: [], 102 | interceptors: [imageInterceptor], 103 | matcher: '/example/:pageParam' 104 | }); 105 | ``` 106 | -------- 107 | 108 | ### Hooks 109 | 110 | Hooks are responsible for modifying loaded dom content. Best use case for hooks is removing unnecessary assets for Google. As page is already rendered with clientside javascript, they are useless now. 111 | 112 | ```js 113 | const dynamicRender = require('dynamic-render'); 114 | const clearJS = dynamicRender.hook({ 115 | name: 'Clear JS', 116 | handler: async (page) => { 117 | await page.evaluate(() => { 118 | const elements = document.getElementsByTagName('script'); 119 | while (elements.length > 0) { 120 | const [target] = elements; 121 | target.parentNode.removeChild(target); 122 | } 123 | }); 124 | }, 125 | }) 126 | ``` 127 | 128 | Hooks can be shared across pages. To register a hook to a page you use hooks property of it. 129 | 130 | **Example hooks:** 131 | 132 |
133 | 134 | **Remove comments from DOM** 135 | 136 | > Comments for humans, not for search engine scrapers. 137 | 138 | ```javascript 139 | /* eslint-disable no-cond-assign */ 140 | import dynamicRender from 'dynamic-render'; 141 | 142 | const clearComments = dynamicRender.hook({ 143 | name: 'Clear comments', 144 | handler: async (page) => { 145 | await page.evaluate(() => { 146 | const nodeIterator = document.createNodeIterator( 147 | document, 148 | NodeFilter.SHOW_COMMENT, 149 | ); 150 | let currentNode; 151 | 152 | while (currentNode = nodeIterator.nextNode()) { 153 | currentNode.parentNode.removeChild(currentNode); 154 | } 155 | }); 156 | }, 157 | }); 158 | ``` 159 | 160 | **Async DOM element handler** 161 | 162 | > We're reducing DOM asset size with CDN's. Also we detach from DOM, when user intercept specific area, we load it. Called as lazy load. Dynamic render can trigger intercept and wait lazy loading. 163 | 164 | ```javascript 165 | 166 | import dynamicRender from 'dynamic-render'; 167 | 168 | const loader = (name, container, element, lazyImage) => dynamicRender.hook({ 169 | name, 170 | handler: async (page) => { 171 | const containerExists = await page.evaluate((container) => { 172 | const containerElement = document.querySelector(container); 173 | if (containerElement) { 174 | window.scrollBy(0, containerElement.offsetTop); 175 | } 176 | return Promise.resolve(!!containerElement); 177 | }, container); 178 | if (containerExists) { 179 | await page.waitForSelector(`${element} ${lazyImage}`, { 180 | timeout: 1000, 181 | }).catch(() => true); 182 | } 183 | }, 184 | }); 185 | 186 | // Usage: 187 | /* 188 | const waitForLoad = loader('name-it', '#spesific-div', '#spesific-part', 'img[src*="cool-cdn-url"]'); 189 | */ 190 | ``` 191 | 192 | *Feel free to publish your killer hooks with world!* 193 | 194 |
195 | 196 | **Usage:** 197 | 198 | ```js 199 | const examplePage = dynamicRender.page({ 200 | name: 'example-page', 201 | hooks: [clearJS], 202 | interceptors: [], 203 | matcher: '/example/:pageParam' 204 | }); 205 | ``` 206 | 207 | -------- 208 | 209 | ### Page 210 | 211 | Pages represent your controllers. An application might have multiple pages and you can provide different configurations for them. 212 | ```js 213 | const productDetailPage = dynamicRender.page({ 214 | name: 'product-detail', 215 | hooks: [jsAssetCleaner], 216 | interceptors: [imageInterceptor], 217 | matcher: '/example/:pageParam', 218 | emulateOptions: { 219 | userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', 220 | viewport: { 221 | width: 414, 222 | height: 736, 223 | deviceScaleFactor: 3, 224 | isMobile: true, 225 | hasTouch: true, 226 | isLandscape: false 227 | } 228 | }, 229 | waitMethod: 'load', 230 | query: { 231 | test: 12345, 232 | qa: 'GA-XXXXX' 233 | }, 234 | followRedirects: true 235 | }); 236 | ``` 237 | 238 | | Property | Required | Description | 239 | |----------------|----------|---------------------------------------------------------------| 240 | | name | true | Name of the page | 241 | | hooks | false | Array of Hooks | 242 | | interceptors | false | Array of Interceptors | 243 | | matcher | true | Matches url with page. Express-like matchers are accepted | 244 | | emulateOptions | false | Default values are provided below, rendering options | 245 | | waitMethod | false | Default value is 'load', you can check Puppeteer wait methods | 246 | | query | false | Default value is '{}', you can pass query strings to matched url | 247 | | followRedirects | false | Default value is 'true', you can pass false for not for follow incoming redirects. | 248 | 249 | Default emulate options are 250 | 251 | ```js 252 | { 253 | userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', 254 | viewport: { 255 | width: 414, 256 | height: 736, 257 | deviceScaleFactor: 3, 258 | isMobile: true, 259 | hasTouch: true, 260 | isLandscape: false 261 | } 262 | } 263 | ``` 264 | 265 | Page emulate options are not required when emulateOptions provided at application level. If you want to override application level configuration you can use page emulate options. 266 | 267 | -------- 268 | 269 | ### Application 270 | Applications are the top level configuration for hosts 271 | 272 | ```js 273 | dynamicRender.application('mobile-web', { 274 | pages: [productDetailPage], 275 | origin: 'https://m.trendyol.com' 276 | }); 277 | ``` 278 | 279 | | Property | Required | Description | 280 | |----------------|----------|----------------------------------------------------------| 281 | | pages | true | Array of Pages | 282 | | origin | true | http://targethost.com | 283 | | emulateOptions | false | Application level emulate options that affects all pages | 284 | | plugins | false | Plugin instance can be used for custom caching strategies | 285 | 286 | 287 | ### Plugins 288 | Plugins can be injected into applications to program custom caching strategies. 289 | 290 | ```typescript 291 | class CachePlugin implements Plugin { 292 | private cache: Map = new Map(); 293 | 294 | async onBeforeStart(){ 295 | console.log('Make some connections'); 296 | } 297 | 298 | async onBeforeRender(page: Page, url: string){ 299 | const existing = this.cache.get(url); 300 | 301 | if(existing){ 302 | return existing; 303 | } 304 | } 305 | 306 | async onAfterRender(page: Page, url: string, renderResult: RenderResult){ 307 | this.cache.set(url, renderResult); 308 | } 309 | } 310 | 311 | dynamicRender.application('mobile-web', { 312 | pages: [productDetailPage], 313 | origin: 'https://m.trendyol.com', 314 | plugins: [new CachePlugin()] 315 | }); 316 | ``` 317 | -------------------------------------------------------------------------------- /__tests__/application.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import * as faker from "faker"; 3 | import {expect} from "chai"; 4 | import {Application, ApplicationConfig} from "../src/application"; 5 | import express from "express"; 6 | import {createExpressRequestMock, createExpressResponseMock} from "./helpers"; 7 | 8 | const sandbox = sinon.createSandbox(); 9 | let application: Application; 10 | 11 | const throwErrorMock = () => { 12 | console.trace(); 13 | throw new Error(`Mocked method call`); 14 | }; 15 | 16 | describe('[application.ts]', () => { 17 | beforeEach(() => { 18 | const configuration = { 19 | origin: faker.internet.url(), 20 | pages: [] 21 | }; 22 | 23 | application = new Application(configuration); 24 | }); 25 | 26 | afterEach(() => { 27 | sandbox.verifyAndRestore(); 28 | }); 29 | 30 | it('should create new Application', () => { 31 | // Arrange 32 | const configuration = { 33 | origin: faker.internet.url(), 34 | pages: [] 35 | }; 36 | 37 | const application = new Application(configuration); 38 | 39 | // Assert 40 | expect(application).to.be.instanceOf(Application); 41 | }); 42 | 43 | it('should convert application to json', () => { 44 | // Arrange 45 | const configuration: ApplicationConfig = { 46 | origin: faker.internet.url(), 47 | pages: [], 48 | emulateOptions: { 49 | userAgent: faker.random.word() 50 | } 51 | }; 52 | 53 | const application = new Application(configuration); 54 | 55 | // Act 56 | const json = JSON.stringify(application); 57 | 58 | // Assert 59 | expect(json).to.eq(JSON.stringify({ 60 | pages: [], 61 | emulateOptions: { 62 | userAgent: configuration.emulateOptions!.userAgent 63 | } 64 | })); 65 | }); 66 | 67 | it('should start application by adding routes', async () => { 68 | // Arrange 69 | const configuration: ApplicationConfig = { 70 | origin: faker.internet.url(), 71 | pages: [{ 72 | configuration: { 73 | matcher: faker.random.word(), 74 | name: faker.random.word(), 75 | }, 76 | handle: {} 77 | } as any], 78 | emulateOptions: { 79 | userAgent: faker.random.word() 80 | } 81 | }; 82 | 83 | const router = { 84 | get: throwErrorMock, 85 | use: throwErrorMock 86 | }; 87 | 88 | const routerMock = sandbox.mock(router); 89 | 90 | 91 | routerMock 92 | .expects('get') 93 | .once() 94 | .withExactArgs(configuration.pages[0].configuration.matcher, configuration.pages[0].handle); 95 | 96 | sandbox 97 | .stub(express, 'Router') 98 | .returns(router as any); 99 | 100 | const application = new Application(configuration); 101 | 102 | routerMock 103 | .expects('use') 104 | .once() 105 | .withExactArgs(application.applicationInfoMiddleware); 106 | 107 | 108 | 109 | // Act 110 | await application.init(); 111 | }); 112 | 113 | it('should handle application status response', () => { 114 | // Arrange 115 | const configuration: ApplicationConfig = { 116 | origin: faker.internet.url(), 117 | pages: [], 118 | emulateOptions: { 119 | userAgent: faker.random.word() 120 | } 121 | }; 122 | 123 | const application = new Application(configuration); 124 | const requestMock = createExpressRequestMock(sandbox); 125 | const responseMock = createExpressResponseMock(sandbox); 126 | 127 | // Act 128 | application.handleStatus(requestMock, responseMock); 129 | 130 | // Assert 131 | expect(responseMock.json.calledWithExactly(application.configuration)).to.eq(true); 132 | }); 133 | 134 | it('should add application information for page and render engine', () => { 135 | // Arrange 136 | const configuration: ApplicationConfig = { 137 | origin: faker.internet.url(), 138 | pages: [], 139 | emulateOptions: { 140 | userAgent: faker.random.word() 141 | } 142 | }; 143 | 144 | const application = new Application(configuration); 145 | const requestMock = createExpressRequestMock(sandbox); 146 | const responseMock = createExpressResponseMock(sandbox); 147 | const next = sandbox.stub(); 148 | 149 | // Act 150 | application.applicationInfoMiddleware(requestMock, responseMock, next); 151 | 152 | // Assert 153 | expect(next.calledOnce).to.eq(true); 154 | expect(requestMock.application).to.deep.eq({ 155 | origin: application.configuration.origin, 156 | emulateOptions: application.configuration.emulateOptions 157 | }) 158 | }); 159 | 160 | describe('should connect and init plugins', () => { 161 | it('should connect and init plugins with on start ', async () => { 162 | // Arrange 163 | const plugin = { 164 | onBeforeStart: sandbox.stub().resolves() 165 | }; 166 | const configuration: ApplicationConfig = { 167 | origin: faker.internet.url(), 168 | pages: [{ 169 | configuration: { 170 | matcher: faker.random.word(), 171 | name: faker.random.word(), 172 | }, 173 | handle: {} 174 | } as any], 175 | emulateOptions: { 176 | userAgent: faker.random.word() 177 | }, 178 | plugins: [ 179 | plugin 180 | ] 181 | }; 182 | 183 | const routerMock = { 184 | get: sandbox.stub(), 185 | use: sandbox.stub() 186 | }; 187 | 188 | sandbox.stub(express, 'Router').returns(routerMock as any); 189 | 190 | const application = new Application(configuration); 191 | 192 | // Act 193 | await application.init(); 194 | 195 | // Assert 196 | expect(plugin.onBeforeStart.calledOnce).to.eq(true); 197 | expect(routerMock.use.calledWithExactly(application.applicationInfoMiddleware)).to.eq(true); 198 | expect(routerMock.get.calledWithExactly(configuration.pages[0].configuration.matcher, configuration.pages[0].handle)).to.eq(true); 199 | }); 200 | 201 | it('should connect and init plugins without on start ', async () => { 202 | // Arrange 203 | const plugin = {}; 204 | const configuration: ApplicationConfig = { 205 | origin: faker.internet.url(), 206 | pages: [{ 207 | configuration: { 208 | matcher: faker.random.word(), 209 | name: faker.random.word(), 210 | }, 211 | handle: {} 212 | } as any], 213 | emulateOptions: { 214 | userAgent: faker.random.word() 215 | }, 216 | plugins: [ 217 | plugin 218 | ] 219 | }; 220 | 221 | const routerMock = { 222 | get: sandbox.stub(), 223 | use: sandbox.stub() 224 | }; 225 | 226 | sandbox.stub(express, 'Router').returns(routerMock as any); 227 | 228 | const application = new Application(configuration); 229 | 230 | // Act 231 | await application.init(); 232 | 233 | // Assert 234 | expect(routerMock.use.calledWithExactly(application.applicationInfoMiddleware)).to.eq(true); 235 | expect(routerMock.get.calledWith(configuration.pages[0].configuration.matcher, configuration.pages[0].handle)).to.eq(true); 236 | }); 237 | }); 238 | }); 239 | -------------------------------------------------------------------------------- /__tests__/dynamic-render.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import {SinonMock} from "sinon"; 3 | import {expect} from "chai"; 4 | import {DynamicRender, PrerenderDefaultConfiguration, WorkerRenderOptions} from "../src/dynamic-render"; 5 | import {Server} from "../src/server"; 6 | import {Engine} from "../src/engine"; 7 | import {Hook, HookConfiguration} from "../src/hook"; 8 | import * as faker from "faker"; 9 | import {Interceptor, InterceptorConfiguration} from "../src/interceptor"; 10 | import {Page, PageSettings} from "../src/page"; 11 | import {Application, ApplicationConfig} from "../src/application"; 12 | import {createExpressRequestMock, createExpressResponseMock} from "./helpers"; 13 | import {ResponseCache} from "../src/response-cache"; 14 | 15 | const sandbox = sinon.createSandbox(); 16 | 17 | const responseCache = new ResponseCache(); 18 | const server = new Server(); 19 | const renderer = new Engine(responseCache); 20 | 21 | let dynamicRender: DynamicRender; 22 | 23 | let serverMock: SinonMock; 24 | let engineMock: SinonMock; 25 | 26 | describe('[prerender.ts]', () => { 27 | beforeEach(() => { 28 | serverMock = sandbox.mock(server); 29 | engineMock = sandbox.mock(renderer); 30 | dynamicRender = new DynamicRender(server, renderer); 31 | }); 32 | 33 | afterEach(() => { 34 | sandbox.verifyAndRestore(); 35 | }); 36 | 37 | it('should create new DynamicRender', () => { 38 | // Arrange 39 | const dynamicRender = new DynamicRender(server, renderer); 40 | 41 | // Assert 42 | expect(dynamicRender).to.be.instanceOf(DynamicRender); 43 | }); 44 | 45 | it('should start server', async () => { 46 | // Arrange 47 | const port = 8080; //Default port 48 | serverMock.expects('listen').withExactArgs(port).resolves(port); 49 | engineMock.expects('init'); 50 | 51 | const dynamicRender = new DynamicRender(server, renderer); 52 | 53 | // Act 54 | await dynamicRender.start(); 55 | }); 56 | 57 | it('should create new hook', () => { 58 | // Arrange 59 | const hooksProps: HookConfiguration = { 60 | name: faker.random.word(), 61 | handler: sandbox.spy() 62 | }; 63 | 64 | // Act 65 | const hook = dynamicRender.hook(hooksProps); 66 | 67 | // Assert 68 | expect(hook).to.be.instanceOf(Hook); 69 | }); 70 | 71 | it('should create new interceptor', () => { 72 | // Arrange 73 | const interceptorProps: InterceptorConfiguration = { 74 | name: faker.random.word(), 75 | handler: sandbox.spy() 76 | }; 77 | 78 | // Act 79 | const interceptor = dynamicRender.interceptor(interceptorProps); 80 | 81 | // Assert 82 | expect(interceptor).to.be.instanceOf(Interceptor); 83 | }); 84 | 85 | it('should create new page', () => { 86 | // Arrange 87 | const pageProps: PageSettings = { 88 | name: faker.random.word(), 89 | matcher: faker.random.word() 90 | }; 91 | 92 | // Act 93 | const page = dynamicRender.page(pageProps); 94 | 95 | // Assert 96 | expect(page).to.be.instanceOf(Page); 97 | }); 98 | 99 | it('should register new application', () => { 100 | // Arrange 101 | const applicationProps: ApplicationConfig = { 102 | origin: faker.random.word(), 103 | pages: [] 104 | }; 105 | const applicationName = faker.random.word(); 106 | 107 | // Act 108 | dynamicRender.application(applicationName, applicationProps); 109 | 110 | // Assert 111 | const application = dynamicRender.applications.get(applicationName); 112 | expect(application).to.be.instanceOf(Application); 113 | expect(application!.configuration).to.include(applicationProps); 114 | }); 115 | 116 | it('should return application status', () => { 117 | // Arrange 118 | const applicationProps: ApplicationConfig = { 119 | origin: faker.random.word(), 120 | pages: [] 121 | }; 122 | const applicationName = faker.random.word(); 123 | const request = createExpressRequestMock(sandbox); 124 | const response = createExpressResponseMock(sandbox); 125 | 126 | // Act 127 | dynamicRender.application(applicationName, applicationProps); 128 | dynamicRender.status(request, response); 129 | 130 | // Assert 131 | expect(response.json.calledOnce).to.eq(true); 132 | expect(response.json.calledWith({ 133 | [applicationName]: dynamicRender.applications.get(applicationName)!.toJSON() 134 | })).to.eq(true); 135 | }); 136 | 137 | it('should init all applications', async () => { 138 | // Arrange 139 | const applicationProps: ApplicationConfig = { 140 | origin: faker.random.word(), 141 | pages: [] 142 | }; 143 | const applicationName = faker.random.word(); 144 | dynamicRender.application(applicationName, applicationProps); 145 | const application = dynamicRender.applications.get(applicationName); 146 | const router = application!.router; 147 | serverMock 148 | .expects('router') 149 | .withExactArgs(`/render/${applicationName}`, router); 150 | serverMock 151 | .expects('listen') 152 | .withExactArgs(8080); 153 | engineMock.expects('init'); 154 | const initStub = sandbox.stub(application!, 'init'); 155 | 156 | // Act 157 | 158 | await dynamicRender.start(); 159 | 160 | // Assert 161 | expect(initStub.calledOnce).to.eq(true); 162 | expect(initStub.calledWithExactly()).to.eq(true); 163 | }); 164 | 165 | it('should init as worker', async () => { 166 | // Arrange 167 | const applicationProps: PrerenderDefaultConfiguration = { 168 | puppeteer: { 169 | [faker.random.word()]: faker.random.word() 170 | } 171 | } as any; 172 | const initStub = sandbox.stub(renderer, 'init'); 173 | 174 | const dynamicRender = new DynamicRender(server, renderer); 175 | 176 | // Act 177 | await dynamicRender.startAsWorker(applicationProps as any); 178 | 179 | // Assert 180 | expect(initStub.firstCall.args[0]).to.include(applicationProps.puppeteer); 181 | }); 182 | 183 | it('should render as worker succesfully', async () => { 184 | // Arrange 185 | const applicationName = faker.random.word(); 186 | const pageName = faker.random.word(); 187 | const origin = faker.random.word(); 188 | const path = faker.random.word(); 189 | const dynamicRender = new DynamicRender(server, renderer); 190 | const renderOptions = { 191 | page: pageName, 192 | application: applicationName, 193 | path: path 194 | } as WorkerRenderOptions; 195 | 196 | const response = faker.random.word(); 197 | const handleAsWorkerStub = sandbox.stub().resolves(response); 198 | 199 | dynamicRender.applications.set(applicationName, { 200 | configuration: { 201 | origin, 202 | pages: [{ 203 | handleAsWorker: handleAsWorkerStub, 204 | configuration: { 205 | name: pageName 206 | } 207 | }] 208 | } 209 | } as any); 210 | 211 | 212 | // Act 213 | const renderResponse = await dynamicRender.renderAsWorker(renderOptions); 214 | 215 | // Assert 216 | expect(handleAsWorkerStub.calledWithExactly(origin, path, undefined)).to.eq(true); 217 | expect(renderResponse).to.eq(response); 218 | }); 219 | 220 | 221 | it('should render as worker when page not found', async () => { 222 | // Arrange 223 | const applicationName = faker.random.word(); 224 | const pageName = faker.random.word(); 225 | const origin = faker.random.word(); 226 | const path = faker.random.word(); 227 | const dynamicRender = new DynamicRender(server, renderer); 228 | const renderOptions = { 229 | page: pageName, 230 | application: applicationName, 231 | path: path 232 | } as WorkerRenderOptions; 233 | 234 | 235 | dynamicRender.applications.set(applicationName, { 236 | configuration: { 237 | origin, 238 | pages: [] 239 | } 240 | } as any); 241 | 242 | 243 | // Act 244 | const renderResponse = await dynamicRender.renderAsWorker(renderOptions); 245 | 246 | // Assert 247 | expect(renderResponse).to.eq(undefined); 248 | }); 249 | 250 | it('should render as worker when application not found', async () => { 251 | // Arrange 252 | const applicationName = faker.random.word(); 253 | const pageName = faker.random.word(); 254 | const path = faker.random.word(); 255 | const dynamicRender = new DynamicRender(server, renderer); 256 | const renderOptions = { 257 | page: pageName, 258 | application: applicationName, 259 | path: path 260 | } as WorkerRenderOptions; 261 | 262 | 263 | // Act 264 | const renderResponse = await dynamicRender.renderAsWorker(renderOptions); 265 | 266 | // Assert 267 | expect(renderResponse).to.eq(undefined); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /__tests__/engine.spec.ts: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import * as faker from "faker"; 3 | import {expect} from "chai"; 4 | import puppeteer from "puppeteer"; 5 | import {Engine} from "../src/engine"; 6 | import {createPuppeteerRequest, createPuppeteerResponse} from "./helpers"; 7 | import {ResponseCache} from "../src/response-cache"; 8 | 9 | const sandbox = sinon.createSandbox(); 10 | let engine: Engine; 11 | 12 | const cache = new ResponseCache(); 13 | let cacheMock; 14 | 15 | describe('[engine.ts]', () => { 16 | beforeEach(() => { 17 | cacheMock = sandbox.mock(cache); 18 | engine = new Engine(cache) 19 | }); 20 | 21 | afterEach(() => { 22 | sandbox.verifyAndRestore(); 23 | }); 24 | 25 | it('should create new Engine', () => { 26 | // Arrange 27 | const engine = new Engine(cache); 28 | 29 | // Assert 30 | expect(engine).to.be.instanceOf(Engine); 31 | }); 32 | 33 | it('should use response cache on response', () => { 34 | // Arrange 35 | const response = createPuppeteerResponse(sandbox); 36 | 37 | // Act 38 | engine.onResponse(response); 39 | }); 40 | 41 | it('should not handle request if responseCache handled', () => { 42 | // Arrange 43 | const continueStub = sandbox.stub(); 44 | const browserPage = sandbox.stub() as any; 45 | const followRedirects = true; 46 | const request = createPuppeteerRequest(sandbox, { 47 | continue: continueStub 48 | }); 49 | const interceptorSpy = sandbox.spy(engine, 'handleInterceptors'); 50 | 51 | // Act 52 | engine.onRequest(request, [], browserPage, followRedirects); 53 | 54 | // Asset 55 | expect(interceptorSpy.notCalled).to.eq(true); 56 | }); 57 | 58 | it('should call interceptors for requests', async () => { 59 | // Arrange 60 | const interceptors = [faker.random.word()] as any; 61 | const request = createPuppeteerRequest(sandbox); 62 | const browserPage = sandbox.stub() as any; 63 | const followRedirects = false; 64 | const interceptorSpy = sandbox.stub(engine, 'handleInterceptors'); 65 | 66 | // Act 67 | await engine.onRequest(request, interceptors, browserPage, followRedirects); 68 | 69 | // Asset 70 | expect(interceptorSpy.calledWithExactly(interceptors, request)).to.eq(true); 71 | }); 72 | 73 | it('should not call interceptors for requests ( followRedirect )', async () => { 74 | // Arrange 75 | const interceptors = [] as any; 76 | const browserPage = sandbox.stub() as any; 77 | const followRedirects = true; 78 | const request = createPuppeteerRequest(sandbox); 79 | request.isNavigationRequest = () => true 80 | request.resourceType = () => 'document' 81 | request.redirectChain = () => [ 82 | {response: () => createPuppeteerResponse(sandbox)}, 83 | {response: () => createPuppeteerResponse(sandbox)}, 84 | ]; 85 | 86 | // Act 87 | await engine.onRequest(request, interceptors, browserPage, followRedirects); 88 | 89 | // Asset 90 | expect(Object.keys(browserPage.redirect).length > 0).to.eq(true); 91 | expect(request.continue.calledOnce).to.eq(false); 92 | expect(request.abort.calledOnce).to.eq(true); 93 | }); 94 | 95 | it('should call continue if there is no interceptor', async () => { 96 | // Arrange 97 | const interceptors = [] as any; 98 | const browserPage = sandbox.stub() as any; 99 | const followRedirects = true; 100 | const request = createPuppeteerRequest(sandbox); 101 | 102 | // Act 103 | await engine.onRequest(request, [], browserPage, followRedirects); 104 | 105 | // Asset 106 | expect(request.continue.calledOnce).to.eq(true); 107 | }); 108 | 109 | it('should create browser', async () => { 110 | // Arrange 111 | const browser = { 112 | on: () => { 113 | throw new Error("Mocked method called") 114 | } 115 | }; 116 | const browserMock = sandbox.mock(browser); 117 | const stub = sandbox.stub(puppeteer, 'launch').returns(browser as any); 118 | browserMock.expects('on').withExactArgs('disconnected', engine.init).once(); 119 | 120 | // Act 121 | await engine.init(); 122 | 123 | // Assert 124 | expect(stub.calledWithExactly({ 125 | headless: true, 126 | ignoreHTTPSErrors: true, 127 | devtools: false, 128 | args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--disable-extensions'] 129 | })).to.eq(true); 130 | }); 131 | 132 | it("should call request with correct params", async () => { 133 | const puppeteerMock = sandbox.mock(puppeteer); 134 | const request = createPuppeteerRequest(sandbox); 135 | const pageStub = { 136 | emulate: sandbox.stub(), 137 | _client: { 138 | send: sandbox.stub() 139 | }, 140 | on: sandbox.stub().withArgs("request", sinon.match.func).callsArgWith(1, request), 141 | setRequestInterception: sandbox.stub() 142 | }; 143 | 144 | const browserStub = { 145 | newPage: sandbox.stub().returns(pageStub), 146 | on: sandbox.stub() 147 | }; 148 | 149 | puppeteerMock.expects("launch").withExactArgs({ 150 | headless: true, 151 | ignoreHTTPSErrors: true, 152 | devtools: false, 153 | args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--disable-extensions'] 154 | }).resolves(browserStub); 155 | 156 | await engine.init(); 157 | await engine.createPage({} as any, [] as any, true) 158 | }); 159 | 160 | 161 | it('should create new page and configure', async () => { 162 | // Arrange 163 | const pageStub = { 164 | emulate: sandbox.stub(), 165 | _client: { 166 | send: sandbox.stub() 167 | }, 168 | on: sandbox.stub(), 169 | setRequestInterception: sandbox.stub() 170 | }; 171 | const browserStub = { 172 | newPage: sandbox.stub().returns(pageStub), 173 | on: sandbox.stub() 174 | }; 175 | sandbox.stub(puppeteer, 'launch').returns(browserStub as any); 176 | const request = createPuppeteerRequest(sandbox); 177 | const emulateOptions = {}; 178 | const interceptors = [] as any; 179 | const followRedirects = true; 180 | const boundMethod = { 181 | request: {}, 182 | interceptors: [], 183 | browserPage: browserStub, 184 | followRedirects, 185 | }; 186 | engine.onRequest = sandbox.stub(); 187 | 188 | // Act 189 | await engine.init(); 190 | const page = await engine.createPage(emulateOptions, interceptors, followRedirects); 191 | 192 | // Assert 193 | expect(page).to.be.an('object'); 194 | expect(pageStub.emulate.calledWithExactly(emulateOptions)).to.eq(true); 195 | expect(pageStub._client.send.calledWithExactly('Network.setBypassServiceWorker', {bypass: true})).to.eq(true); 196 | expect(pageStub._client.send.calledWithExactly('Network.setCacheDisabled', { 197 | cacheDisabled: false 198 | })).to.eq(true); 199 | expect(pageStub.setRequestInterception.calledWithExactly(true)).to.eq(true); 200 | expect(pageStub.on.calledWithExactly('request', sinon.match.func)).to.eq(true); 201 | expect(pageStub.on.calledWithExactly('response', engine.onResponse)).to.eq(true); 202 | 203 | pageStub.on.withArgs("request").callsArgWith(2, request); 204 | }); 205 | 206 | it('should create new page and configure with initial page', async () => { 207 | // Arrange 208 | const pageStub = { 209 | emulate: sandbox.stub(), 210 | _client: { 211 | send: sandbox.stub() 212 | }, 213 | on: sandbox.stub(), 214 | setRequestInterception: sandbox.stub() 215 | }; 216 | const browserStub = { 217 | newPage: sandbox.stub().returns(pageStub), 218 | on: sandbox.stub() 219 | }; 220 | sandbox.stub(puppeteer, 'launch').returns(browserStub as any); 221 | const request = createPuppeteerRequest(sandbox,{ 222 | method: sandbox.stub().returns('GET'), 223 | resourceType: sandbox.stub().returns('document') 224 | }); 225 | const emulateOptions = {}; 226 | const interceptors = [] as any; 227 | const followRedirects = true; 228 | engine.onRequest = sandbox.stub(); 229 | const initial = { 230 | html: faker.random.word(), 231 | statusCode: faker.random.number(), 232 | headers: {} 233 | }; 234 | 235 | 236 | pageStub.on.withArgs("request").callsArgWith(1, request); 237 | 238 | // Act 239 | await engine.init(); 240 | await engine.createPage(emulateOptions, interceptors, followRedirects, initial); 241 | 242 | // Assert 243 | expect((engine.onRequest as any).called).to.eq(false); 244 | expect(request.respond.calledWithExactly({ 245 | body: initial.html, 246 | status: +initial.statusCode, 247 | headers: initial.headers 248 | })).to.eq(true); 249 | }); 250 | 251 | describe('Rendering', () => { 252 | it('should handle render process (no hooks)', async () => { 253 | // Arrange 254 | const pageContent = faker.random.word(); 255 | const pageStatus = 200; 256 | const pageStub = { 257 | goto: sandbox.stub().returns({ 258 | status: sandbox.stub().returns(pageStatus) 259 | }), 260 | content: sandbox.stub().returns(pageContent), 261 | close: sandbox.stub().resolves() 262 | }; 263 | const createPageStub = sandbox.stub(engine, 'createPage').resolves(pageStub as any); 264 | const renderOptions = { 265 | followRedirects: true, 266 | emulateOptions: {}, 267 | url: faker.random.word(), 268 | waitMethod: faker.random.word(), 269 | hooks: [], 270 | interceptors: [], 271 | initial: { 272 | html: faker.random.word(), 273 | statusCode: faker.random.number(), 274 | headers: {} 275 | } 276 | }; 277 | 278 | // Act 279 | const content = await engine.render(renderOptions as any); 280 | 281 | // Assert 282 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 283 | expect(pageStub.goto.calledWithExactly(renderOptions.url, {waitUntil: renderOptions.waitMethod})).to.eq(true); 284 | expect(pageStub.close.calledOnce).to.eq(true); 285 | expect(content).to.deep.eq({ 286 | status: pageStatus, 287 | html: pageContent 288 | }); 289 | }); 290 | 291 | it('should handle render process (null navigation)', async () => { 292 | // Arrange 293 | const pageContent = faker.random.word(); 294 | const pageStatus = 200; 295 | const pageStub = { 296 | goto: sandbox.stub().returns(null), 297 | content: sandbox.stub().returns(pageContent), 298 | close: sandbox.stub().resolves() 299 | }; 300 | const createPageStub = sandbox.stub(engine, 'createPage').resolves(pageStub as any); 301 | const renderOptions = { 302 | followRedirects: true, 303 | emulateOptions: {}, 304 | url: faker.random.word(), 305 | waitMethod: faker.random.word(), 306 | hooks: [], 307 | interceptors: [], 308 | initial: { 309 | html: faker.random.word(), 310 | statusCode: faker.random.number(), 311 | headers: {} 312 | } 313 | }; 314 | 315 | // Act 316 | const content = await engine.render(renderOptions as any); 317 | 318 | // Assert 319 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 320 | expect(pageStub.goto.calledWithExactly(renderOptions.url, {waitUntil: renderOptions.waitMethod})).to.eq(true); 321 | expect(pageStub.close.calledOnce).to.eq(true); 322 | expect(content).to.deep.eq({ 323 | status: 503, 324 | html: '' 325 | }); 326 | }); 327 | 328 | it('should handle render process (page creation failed)', async () => { 329 | // Arrange 330 | const renderOptions = { 331 | followRedirects: false, 332 | emulateOptions: {}, 333 | url: faker.random.word(), 334 | waitMethod: faker.random.word(), 335 | hooks: [], 336 | interceptors: [], 337 | initial: { 338 | html: faker.random.word(), 339 | statusCode: faker.random.number(), 340 | headers: {} 341 | } 342 | }; 343 | const createPageError = new Error(faker.random.word()) 344 | const createPageStub = sandbox.stub(engine, 'createPage').rejects(createPageError); 345 | 346 | // Act 347 | const content = await engine.render(renderOptions as any); 348 | 349 | // Assert 350 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 351 | expect(content).to.deep.eq({ 352 | status: 503, 353 | html: '' 354 | }); 355 | }); 356 | 357 | 358 | it('should handle render process (with hooks)', async () => { 359 | // Arrange 360 | const pageContent = faker.random.word(); 361 | const pageStatus = 200; 362 | const pageStub = { 363 | goto: sandbox.stub().returns({ 364 | status: sandbox.stub().returns(pageStatus) 365 | }), 366 | content: sandbox.stub().returns(pageContent), 367 | close: sandbox.stub().resolves(pageContent) 368 | }; 369 | const createPageStub = sandbox.stub(engine, 'createPage').resolves(pageStub as any); 370 | const hook = { 371 | handle: sandbox.stub() 372 | }; 373 | const renderOptions = { 374 | followRedirects: true, 375 | emulateOptions: {}, 376 | url: faker.random.word(), 377 | waitMethod: faker.random.word(), 378 | hooks: [hook], 379 | interceptors: [], 380 | initial: { 381 | html: faker.random.word(), 382 | statusCode: faker.random.number(), 383 | headers: {} 384 | } 385 | }; 386 | 387 | // Act 388 | const content = await engine.render(renderOptions as any); 389 | 390 | // Assert 391 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 392 | expect(pageStub.goto.calledWithExactly(renderOptions.url, {waitUntil: renderOptions.waitMethod})).to.eq(true); 393 | expect(pageStub.close.calledOnce).to.eq(true); 394 | expect(content).to.deep.eq({ 395 | status: pageStatus, 396 | html: pageContent 397 | }); 398 | expect(hook.handle.calledWithExactly(pageStub)).to.eq(true); 399 | }); 400 | 401 | it('should handle render process (with followRedirect)', async () => { 402 | // Arrange 403 | const pageHeaders = {location: faker.internet.url()}; 404 | const pageContent = ''; 405 | const pageStatus = 301; 406 | const pageStub = { 407 | goto: sandbox.stub().returns({ 408 | status: pageStatus, 409 | headers: sandbox.stub().returns(pageHeaders), 410 | }), 411 | content: sandbox.stub().returns(pageContent), 412 | close: sandbox.stub().resolves(pageContent), 413 | redirect: { 414 | status: sandbox.stub().returns(pageStatus), 415 | headers: sandbox.stub().returns(pageHeaders), 416 | } 417 | }; 418 | const createPageStub = sandbox.stub(engine, 'createPage').resolves(pageStub as any); 419 | const hook = { 420 | handle: sandbox.stub() 421 | }; 422 | const renderOptions = { 423 | followRedirects: true, 424 | emulateOptions: {}, 425 | url: faker.internet.url(), 426 | waitMethod: faker.random.word(), 427 | hooks: [hook], 428 | interceptors: [], 429 | initial: { 430 | html: faker.random.word(), 431 | statusCode: faker.random.number(), 432 | headers: {} 433 | } 434 | }; 435 | 436 | // Act 437 | const content = await engine.render(renderOptions as any); 438 | 439 | // Assert 440 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 441 | expect(pageStub.goto.calledWithExactly(renderOptions.url, {waitUntil: renderOptions.waitMethod})).to.eq(true); 442 | expect(content.status).to.eq(pageStatus); 443 | expect(content.html).to.eq(""); 444 | expect(pageStub.close.calledOnce).to.eq(true); 445 | }); 446 | 447 | it('should handle render process (navigation fails)', async () => { 448 | // Arrange 449 | const pageContent = faker.random.word(); 450 | const goToStubError = new Error(faker.random.word()); 451 | const pageStub = { 452 | goto: sandbox.stub().rejects(goToStubError), 453 | content: sandbox.stub().returns(pageContent), 454 | close: sandbox.stub().resolves(pageContent), 455 | }; 456 | const createPageStub = sandbox.stub(engine, 'createPage').resolves(pageStub as any); 457 | const renderOptions = { 458 | followRedirects: true, 459 | emulateOptions: {}, 460 | url: faker.random.word(), 461 | waitMethod: faker.random.word(), 462 | hooks: [], 463 | interceptors: [], 464 | initial: { 465 | html: faker.random.word(), 466 | statusCode: faker.random.number(), 467 | headers: {} 468 | } 469 | }; 470 | 471 | // Act 472 | const content = await engine.render(renderOptions as any); 473 | 474 | // Assert 475 | expect(createPageStub.calledWithExactly(renderOptions.emulateOptions, renderOptions.interceptors, renderOptions.followRedirects, renderOptions.initial)).to.eq(true); 476 | expect(pageStub.goto.calledWithExactly(renderOptions.url, {waitUntil: renderOptions.waitMethod})).to.eq(true); 477 | expect(pageStub.close.calledOnce).to.eq(true); 478 | expect(content).to.deep.eq({ 479 | status: 503, 480 | html: '', 481 | }); 482 | }); 483 | }); 484 | 485 | describe('Interceptors', () => { 486 | it('should handle interceptors (no handle)', async () => { 487 | // Arrange 488 | const request = createPuppeteerRequest(sandbox); 489 | const interceptor = { 490 | handle: sandbox.stub() 491 | }; 492 | const interceptors = [interceptor] as any; 493 | 494 | // Act 495 | await engine.handleInterceptors(interceptors, request); 496 | 497 | // Assert 498 | expect(interceptor.handle.calledOnce).to.eq(true); 499 | }); 500 | 501 | it('should handle interceptors (blocks)', async () => { 502 | // Arrange 503 | const request = createPuppeteerRequest(sandbox); 504 | const interceptor = { 505 | handle: sandbox.stub().callsArg(2) 506 | }; 507 | const interceptor2 = { 508 | handle: sandbox.stub() 509 | }; 510 | const interceptors = [interceptor, interceptor2] as any; 511 | 512 | // Act 513 | await engine.handleInterceptors(interceptors, request); 514 | 515 | // Assert 516 | expect(interceptor.handle.calledOnce).to.eq(true); 517 | expect(interceptor2.handle.called).to.eq(false); 518 | expect(request.abort.calledWithExactly('blockedbyclient')).to.eq(true); 519 | expect(request.continue.called).to.eq(false); 520 | }); 521 | 522 | it('should handle interceptors (responds)', async () => { 523 | // Arrange 524 | const request = createPuppeteerRequest(sandbox); 525 | const options = {}; 526 | const interceptor = { 527 | handle: sandbox.stub().callsArgWith(1, options) 528 | }; 529 | const interceptor2 = { 530 | handle: sandbox.stub() 531 | }; 532 | const interceptors = [interceptor, interceptor2] as any; 533 | 534 | // Act 535 | await engine.handleInterceptors(interceptors, request); 536 | 537 | // Assert 538 | expect(interceptor.handle.calledOnce).to.eq(true); 539 | expect(interceptor2.handle.called).to.eq(false); 540 | expect(request.respond.calledWithExactly(options)).to.eq(true); 541 | expect(request.continue.called).to.eq(false); 542 | }); 543 | }); 544 | }); 545 | -------------------------------------------------------------------------------- /__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import {SinonSandbox, SinonStub} from "sinon"; 2 | 3 | export const createExpressResponseMock = (sandbox: SinonSandbox, props?: Record) => ({ 4 | json: sandbox.stub().returnsThis(), 5 | send: sandbox.stub().returnsThis(), 6 | end: sandbox.stub(), 7 | set: sandbox.stub().returnsThis(), 8 | write: sandbox.stub().returnsThis(), 9 | status: sandbox.stub().returnsThis(), 10 | headers: sandbox.stub().returnsThis(), 11 | html: sandbox.stub().returnsThis(), 12 | setHeader: sandbox.stub(), 13 | ...props 14 | }) as any; 15 | 16 | export const createExpressRequestMock = (sandbox: SinonSandbox) => ({ 17 | query: {}, 18 | cookies: {} 19 | }) as any; 20 | 21 | export const createPuppeteerRequest = (sandbox: SinonSandbox, props?: Record) => ({ 22 | url: sandbox.stub(), 23 | respond: sandbox.stub(), 24 | resourceType: sandbox.stub(), 25 | method: sandbox.stub(), 26 | continue: sandbox.stub(), 27 | abort: sandbox.stub(), 28 | request: sandbox.stub(), 29 | isNavigationRequest: sandbox.stub(), 30 | ...props 31 | }) as any; 32 | 33 | export const createPuppeteerResponse = (sandbox: SinonSandbox, props?: Record) => ({ 34 | url: sandbox.stub(), 35 | headers: sandbox.stub(), 36 | status: sandbox.stub(), 37 | buffer: sandbox.stub(), 38 | ...props 39 | }) as any; 40 | 41 | export const createInterceptor = (sandbox: SinonSandbox, props?: Record) => ({ 42 | handle: sandbox.stub(), 43 | ...props 44 | }); 45 | -------------------------------------------------------------------------------- /__tests__/hook.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import * as faker from "faker"; 3 | import {expect} from "chai"; 4 | import {Hook, HookConfiguration} from "../src/hook"; 5 | 6 | const sandbox = sinon.createSandbox(); 7 | let hook: Hook; 8 | 9 | describe('[hook.ts]', () => { 10 | beforeEach(() => { 11 | hook = new Hook({ 12 | name: faker.random.word(), 13 | handler: sandbox.stub() 14 | }) 15 | }); 16 | 17 | afterEach(() => { 18 | sandbox.verifyAndRestore(); 19 | }); 20 | 21 | it('should create new Hook', () => { 22 | // Arrange 23 | const options: HookConfiguration = { 24 | name: faker.random.word(), 25 | handler: sandbox.stub() 26 | }; 27 | const hook = new Hook(options); 28 | 29 | // Assert 30 | expect(hook).to.be.instanceOf(Hook); 31 | }); 32 | 33 | it('should call handler on handle method', async () => { 34 | // Arrange 35 | const options = { 36 | name: faker.random.word(), 37 | handler:sandbox.stub() 38 | }; 39 | const hook = new Hook(options); 40 | const page = {} as any; 41 | 42 | // Act 43 | await hook.handle(page); 44 | 45 | // Assert 46 | expect(options.handler.calledWithExactly(page)).to.eq(true); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect} from "chai"; 2 | import dynamicRender from "../src"; 3 | import {DynamicRender} from "../src/dynamic-render"; 4 | 5 | 6 | describe('[index.ts]', () => { 7 | it('should export Dynamic Render instance', () => { 8 | // Assert 9 | expect(dynamicRender).to.be.instanceOf(DynamicRender); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /__tests__/interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import * as faker from "faker"; 3 | import {expect} from "chai"; 4 | import {Interceptor, InterceptorConfiguration} from "../src/interceptor"; 5 | import {createPuppeteerRequest} from "./helpers"; 6 | 7 | const sandbox = sinon.createSandbox(); 8 | let interceptor: Interceptor; 9 | 10 | describe('[interceptor.ts]', () => { 11 | beforeEach(() => { 12 | interceptor = new Interceptor({ 13 | name: faker.random.word(), 14 | handler: sandbox.stub() 15 | }) 16 | }); 17 | 18 | afterEach(() => { 19 | sandbox.verifyAndRestore(); 20 | }); 21 | 22 | it('should create new Interceptor', () => { 23 | // Arrange 24 | const options: InterceptorConfiguration = { 25 | name: faker.random.word(), 26 | handler: sandbox.stub() 27 | }; 28 | const interceptor = new Interceptor(options); 29 | 30 | // Assert 31 | expect(interceptor).to.be.instanceOf(Interceptor); 32 | }); 33 | 34 | it('should call handler on handle method', async () => { 35 | // Arrange 36 | const options = { 37 | name: faker.random.word(), 38 | handler:sandbox.stub() 39 | }; 40 | const interceptor = new Interceptor(options); 41 | const request = createPuppeteerRequest(sandbox, { 42 | url: sandbox.stub().returns(faker.random.word()) 43 | }); 44 | const block = sandbox.stub(); 45 | const respond = sandbox.stub(); 46 | 47 | // Act 48 | await interceptor.handle(request, respond, block); 49 | 50 | // Assert 51 | expect(options.handler.calledWithExactly(request, respond, block)).to.eq(true); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /__tests__/page.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import {SinonMock} from "sinon"; 3 | import * as faker from "faker"; 4 | import {expect} from "chai"; 5 | import {Page, PageSettings} from "../src/page"; 6 | import {Engine} from "../src/engine"; 7 | import {createExpressResponseMock} from "./helpers"; 8 | import {ResponseCache} from "../src/response-cache"; 9 | 10 | const responseCache = new ResponseCache(); 11 | const sandbox = sinon.createSandbox(); 12 | const engine = new Engine(responseCache); 13 | 14 | let engineMock: SinonMock; 15 | let page: Page; 16 | 17 | describe('[page.ts]', () => { 18 | beforeEach(() => { 19 | engineMock = sandbox.mock(engine); 20 | 21 | const pageProps = { 22 | name: faker.random.word(), 23 | matcher: faker.random.word() 24 | }; 25 | 26 | page = new Page(pageProps, engine); 27 | }); 28 | 29 | afterEach(() => { 30 | sandbox.verifyAndRestore(); 31 | }); 32 | 33 | it('should create new Page', () => { 34 | // Arrange 35 | const pageProps = { 36 | name: faker.random.word(), 37 | matcher: faker.random.word() 38 | }; 39 | const page = new Page(pageProps, engine); 40 | 41 | // Assert 42 | expect(page).to.be.instanceOf(Page); 43 | }); 44 | 45 | it('should convert page to json', () => { 46 | // Arrange 47 | const configuration = { 48 | name: faker.random.word(), 49 | matcher: faker.random.word(), 50 | waitMethod: faker.random.word(), 51 | emulateOptions: {}, 52 | interceptors: [{ 53 | name: faker.random.word() 54 | }], 55 | hooks: [{ 56 | name: faker.random.word() 57 | }] 58 | } as unknown as PageSettings; 59 | 60 | const page = new Page(configuration, engine); 61 | 62 | // Act 63 | const json = JSON.stringify(page); 64 | 65 | // Assert 66 | expect(json).to.eq(JSON.stringify({ 67 | matcher: configuration.matcher, 68 | interceptors: [configuration.interceptors![0].name], 69 | hooks: [configuration.hooks![0].name], 70 | waitMethod: configuration.waitMethod, 71 | emulateOptions: configuration.emulateOptions, 72 | query: {}, 73 | followRedirects: true 74 | })) 75 | }); 76 | 77 | it('should call engine for render with cache control', async () => { 78 | // Arrange 79 | const url = '/'; 80 | const origin = faker.internet.url(); 81 | const renderResponse = { 82 | status: 200, 83 | html: faker.random.word() 84 | }; 85 | const response = createExpressResponseMock(sandbox); 86 | const request = { 87 | url, 88 | application: { 89 | origin 90 | }, 91 | }; 92 | 93 | const configuration = { 94 | emulateOptions: faker.random.word(), 95 | interceptors: [faker.random.word()], 96 | hooks: [faker.random.word()], 97 | waitMethod: faker.random.word(), 98 | cacheDurationSeconds: faker.random.number(), 99 | followRedirects: false 100 | }; 101 | 102 | const page = new Page(configuration as any, engine); 103 | 104 | engineMock 105 | .expects('render') 106 | .withExactArgs({ 107 | emulateOptions: configuration.emulateOptions, 108 | url: origin + url, 109 | interceptors: configuration.interceptors, 110 | hooks: configuration.hooks, 111 | waitMethod: configuration.waitMethod, 112 | followRedirects: configuration.followRedirects, 113 | }) 114 | .resolves(renderResponse); 115 | 116 | // Act 117 | await page.handle(request as any, response); 118 | 119 | // Assert 120 | expect(response.set.calledWithExactly('cache-control', `max-age=${configuration.cacheDurationSeconds}, public`)).to.eq(true); 121 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 122 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 123 | }); 124 | 125 | it('should call engine for render without cache control', async () => { 126 | // Arrange 127 | const url = '/'; 128 | const origin = faker.internet.url(); 129 | const renderResponse = { 130 | status: 404, 131 | html: faker.random.word() 132 | }; 133 | const response = createExpressResponseMock(sandbox); 134 | const request = { 135 | url, 136 | application: { 137 | origin 138 | }, 139 | }; 140 | 141 | const query = { 142 | dr: true, 143 | test: false, 144 | }; 145 | 146 | const configuration = { 147 | emulateOptions: faker.random.word(), 148 | interceptors: [faker.random.word()], 149 | hooks: [faker.random.word()], 150 | waitMethod: faker.random.word(), 151 | cacheDurationSeconds: faker.random.number(), 152 | query, 153 | followRedirects: false 154 | }; 155 | 156 | const page = new Page(configuration as any, engine); 157 | 158 | engineMock 159 | .expects('render') 160 | .withExactArgs({ 161 | emulateOptions: configuration.emulateOptions, 162 | url: origin + url + '?dr=true&test=false', 163 | interceptors: configuration.interceptors, 164 | hooks: configuration.hooks, 165 | waitMethod: configuration.waitMethod, 166 | followRedirects: configuration.followRedirects 167 | }) 168 | .resolves(renderResponse); 169 | 170 | // Act 171 | await page.handle(request as any, response); 172 | 173 | // Assert 174 | expect(response.set.called).to.eq(false); 175 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 176 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 177 | }); 178 | 179 | it('should follow redirects when config is set false', async () => { 180 | // Arrange 181 | const url = '/'; 182 | const origin = faker.internet.url(); 183 | const renderResponse = { 184 | status: 301, 185 | html: 'Moved', 186 | headers: { 187 | location: 'https://test.com' 188 | } 189 | }; 190 | const response = createExpressResponseMock(sandbox); 191 | const request = { 192 | url, 193 | application: { 194 | origin 195 | }, 196 | }; 197 | 198 | const query = { 199 | dr: true, 200 | test: false, 201 | }; 202 | 203 | const configuration = { 204 | emulateOptions: faker.random.word(), 205 | interceptors: [faker.random.word()], 206 | hooks: [faker.random.word()], 207 | waitMethod: faker.random.word(), 208 | cacheDurationSeconds: faker.random.number(), 209 | query, 210 | followRedirects: false 211 | }; 212 | 213 | const page = new Page(configuration as any, engine); 214 | 215 | engineMock 216 | .expects('render') 217 | .withExactArgs({ 218 | emulateOptions: configuration.emulateOptions, 219 | url: origin + url + '?dr=true&test=false', 220 | interceptors: configuration.interceptors, 221 | hooks: configuration.hooks, 222 | waitMethod: configuration.waitMethod, 223 | followRedirects: configuration.followRedirects 224 | }) 225 | .resolves(renderResponse); 226 | 227 | // Act 228 | await page.handle(request as any, response); 229 | // Assert 230 | expect(response.set.called).to.eq(true); 231 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 232 | }); 233 | 234 | it('should handle as worker', async () => { 235 | // Arrange 236 | const origin = faker.internet.url(); 237 | const path = '/path'; 238 | engineMock.expects('render').withExactArgs({ 239 | emulateOptions: page.configuration.emulateOptions, 240 | url: origin + path, 241 | interceptors: page.configuration.interceptors, 242 | hooks: page.configuration.hooks, 243 | waitMethod: page.configuration.waitMethod, 244 | followRedirects: page.configuration.followRedirects, 245 | initial: undefined 246 | }).resolves(); 247 | 248 | // Act 249 | await page.handleAsWorker(origin, path); 250 | }); 251 | 252 | describe('Page plugins', () => { 253 | it('should return response from cache', async () => { 254 | // Arrange 255 | const url = '/'; 256 | const origin = faker.internet.url(); 257 | const renderResponse = { 258 | status: 200, 259 | html: faker.random.word() 260 | }; 261 | const response = createExpressResponseMock(sandbox); 262 | const request = { 263 | url, 264 | application: { 265 | origin 266 | }, 267 | originalUrl: faker.random.word() 268 | }; 269 | 270 | const configuration = { 271 | emulateOptions: faker.random.word(), 272 | interceptors: [faker.random.word()], 273 | hooks: [faker.random.word()], 274 | waitMethod: faker.random.word(), 275 | cacheDurationSeconds: faker.random.number(), 276 | followRedirects: false, 277 | }; 278 | 279 | const plugin = { 280 | onBeforeRender: sandbox.stub().resolves(renderResponse), 281 | onAfterRender: sandbox.stub() 282 | }; 283 | 284 | const page = new Page(configuration as any, engine, [plugin]); 285 | 286 | engineMock 287 | .expects('render') 288 | .never(); 289 | 290 | // Act 291 | await page.handle(request as any, response); 292 | 293 | // Assert 294 | expect(plugin.onBeforeRender.calledWithExactly(page, request)).to.eq(true); 295 | expect(response.set.calledWithExactly('cache-control', `max-age=${configuration.cacheDurationSeconds}, public`)).to.eq(true); 296 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 297 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 298 | }); 299 | 300 | it('should continue on cache miss', async () => { 301 | // Arrange 302 | const url = '/'; 303 | const origin = faker.internet.url(); 304 | const renderResponse = { 305 | status: 200, 306 | html: faker.random.word() 307 | }; 308 | const response = createExpressResponseMock(sandbox); 309 | const request = { 310 | url, 311 | application: { 312 | origin 313 | }, 314 | originalUrl: faker.random.word() 315 | }; 316 | 317 | const configuration = { 318 | emulateOptions: faker.random.word(), 319 | interceptors: [faker.random.word()], 320 | hooks: [faker.random.word()], 321 | waitMethod: faker.random.word(), 322 | cacheDurationSeconds: faker.random.number(), 323 | followRedirects: false, 324 | }; 325 | 326 | const plugin = { 327 | onBeforeRender: sandbox.stub().resolves(), 328 | onAfterRender: sandbox.stub().resolves() 329 | }; 330 | 331 | const page = new Page(configuration as any, engine, [plugin]); 332 | 333 | engineMock 334 | .expects('render') 335 | .withExactArgs({ 336 | emulateOptions: configuration.emulateOptions, 337 | url: origin + url, 338 | interceptors: configuration.interceptors, 339 | hooks: configuration.hooks, 340 | waitMethod: configuration.waitMethod, 341 | followRedirects: configuration.followRedirects 342 | }) 343 | .resolves(renderResponse); 344 | 345 | // Act 346 | await page.handle(request as any, response); 347 | 348 | // Assert 349 | expect(plugin.onBeforeRender.calledWithExactly(page, request)).to.eq(true); 350 | expect(plugin.onAfterRender.calledWithExactly(page, request, renderResponse, response)).to.eq(true); 351 | expect(response.set.calledWithExactly('cache-control', `max-age=${configuration.cacheDurationSeconds}, public`)).to.eq(true); 352 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 353 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 354 | }); 355 | 356 | it('should call on after render', async () => { 357 | // Arrange 358 | const url = '/'; 359 | const origin = faker.internet.url(); 360 | const renderResponse = { 361 | status: 200, 362 | html: faker.random.word() 363 | }; 364 | const response = createExpressResponseMock(sandbox); 365 | const request = { 366 | url, 367 | application: { 368 | origin 369 | }, 370 | originalUrl: faker.random.word() 371 | }; 372 | 373 | const configuration = { 374 | emulateOptions: faker.random.word(), 375 | interceptors: [faker.random.word()], 376 | hooks: [faker.random.word()], 377 | waitMethod: faker.random.word(), 378 | cacheDurationSeconds: faker.random.number(), 379 | followRedirects: false, 380 | }; 381 | 382 | const plugin = { 383 | onBeforeRender: sandbox.stub().resolves(), 384 | onAfterRender: sandbox.stub().resolves() 385 | }; 386 | 387 | const page = new Page(configuration as any, engine, [plugin]); 388 | 389 | engineMock 390 | .expects('render') 391 | .withExactArgs({ 392 | emulateOptions: configuration.emulateOptions, 393 | url: origin + url, 394 | interceptors: configuration.interceptors, 395 | hooks: configuration.hooks, 396 | waitMethod: configuration.waitMethod, 397 | followRedirects: configuration.followRedirects 398 | }) 399 | .resolves(renderResponse); 400 | 401 | // Act 402 | await page.handle(request as any, response); 403 | 404 | // Assert 405 | expect(plugin.onBeforeRender.calledWithExactly(page, request)).to.eq(true); 406 | expect(plugin.onAfterRender.calledWithExactly(page, request, renderResponse, response)).to.eq(true); 407 | expect(response.set.calledWithExactly('cache-control', `max-age=${configuration.cacheDurationSeconds}, public`)).to.eq(true); 408 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 409 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 410 | }); 411 | 412 | it('should pass listeners if none of them registered', async () => { 413 | // Arrange 414 | const url = '/'; 415 | const origin = faker.internet.url(); 416 | const renderResponse = { 417 | status: 200, 418 | html: faker.random.word() 419 | }; 420 | const response = createExpressResponseMock(sandbox); 421 | const request = { 422 | url, 423 | application: { 424 | origin 425 | }, 426 | }; 427 | 428 | const configuration = { 429 | emulateOptions: faker.random.word(), 430 | interceptors: [faker.random.word()], 431 | hooks: [faker.random.word()], 432 | waitMethod: faker.random.word(), 433 | cacheDurationSeconds: faker.random.number(), 434 | followRedirects: false, 435 | }; 436 | 437 | const plugin = {}; 438 | 439 | const page = new Page(configuration as any, engine, [plugin]); 440 | 441 | engineMock 442 | .expects('render') 443 | .withExactArgs({ 444 | emulateOptions: configuration.emulateOptions, 445 | url: origin + url, 446 | interceptors: configuration.interceptors, 447 | hooks: configuration.hooks, 448 | waitMethod: configuration.waitMethod, 449 | followRedirects: configuration.followRedirects 450 | }) 451 | .resolves(renderResponse); 452 | 453 | // Act 454 | await page.handle(request as any, response); 455 | 456 | // Assert 457 | expect(response.set.calledWithExactly('cache-control', `max-age=${configuration.cacheDurationSeconds}, public`)).to.eq(true); 458 | expect(response.status.calledWithExactly(renderResponse.status)).to.eq(true); 459 | expect(response.send.calledWithExactly(renderResponse.html)).to.eq(true); 460 | }); 461 | }); 462 | }); 463 | -------------------------------------------------------------------------------- /__tests__/server.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import * as faker from "faker"; 3 | import {expect} from "chai"; 4 | import {Server, setDynamicRenderHeader} from "../src/server"; 5 | import {createExpressRequestMock, createExpressResponseMock} from "./helpers"; 6 | 7 | const sandbox = sinon.createSandbox(); 8 | let server: Server; 9 | 10 | describe('[server.ts]', () => { 11 | beforeEach(() => { 12 | server = new Server() 13 | }); 14 | 15 | afterEach(() => { 16 | sandbox.verifyAndRestore(); 17 | }); 18 | 19 | it('should create new Server', () => { 20 | // Arrange 21 | const server = new Server(); 22 | 23 | // Assert 24 | expect(server).to.be.instanceOf(Server); 25 | }); 26 | 27 | it('should register new path', () => { 28 | // Arrange 29 | const path = faker.random.word(); 30 | const method = 'get'; 31 | const handler = sandbox.stub() as any; 32 | 33 | const appStub = sandbox.stub(server.app, method); 34 | 35 | // Act 36 | server.register(path, method, handler); 37 | 38 | // Assert 39 | expect(appStub.calledWithExactly(path, handler)).to.eq(true); 40 | }); 41 | 42 | it('should register new router', () => { 43 | // Arrange 44 | const path = faker.random.word(); 45 | const router = sandbox.stub() as any; 46 | const appStub = sandbox.stub(server.app, 'use'); 47 | 48 | // Act 49 | server.router(path, router); 50 | 51 | // Assert 52 | expect(appStub.calledWithExactly(path, router)).to.eq(true); 53 | }); 54 | 55 | it('should set dynamic rendering header', () => { 56 | const request = createExpressRequestMock(sandbox); 57 | const response = createExpressResponseMock(sandbox) 58 | const nextStub = sandbox.stub() as any; 59 | setDynamicRenderHeader(request, response, nextStub); 60 | 61 | expect(nextStub.calledWithExactly()).to.eq(true); 62 | }); 63 | 64 | it('should start server and listen port', async () => { 65 | // Arrange 66 | const port = faker.random.number(); 67 | const listenStub = sandbox.stub(server.app, 'listen').callsArg(1); 68 | 69 | // Act 70 | const portResolved = await server.listen(port); 71 | 72 | // Assert 73 | expect(listenStub.calledWithExactly(port, sinon.match.func)).to.eq(true); 74 | expect(portResolved).to.eq(port); 75 | }); 76 | }); -------------------------------------------------------------------------------- /cycle.svg: -------------------------------------------------------------------------------- 1 | RequestDynamicRenderInterceptorHostHook/render/app/page/12{application.origin}/page/12Html ContentAsset(img.png)(img.png not handled)(img.png blocked or handled)Dom is now readyDomUpdated Dom/render/app/page/12RequestDynamicRenderInterceptorHostHook -------------------------------------------------------------------------------- /demo/demo.ts: -------------------------------------------------------------------------------- 1 | import dynamicRender from "../src"; 2 | import fs from "fs"; 3 | import * as path from "path"; 4 | import {Plugin} from "../src/types"; 5 | import {Page} from "../src/page"; 6 | import {RenderResult} from "../src/engine"; 7 | 8 | 9 | const placeholderPng = fs.readFileSync(path.join(__dirname, './png_placeholder')); 10 | const placeholderJpg = fs.readFileSync(path.join(__dirname, './jpg_placeholder')); 11 | 12 | const clearCss = dynamicRender.hook({ 13 | name: 'Clear Css', 14 | handler: async page => { 15 | await page.evaluate(() => { 16 | // Remove Inline css 17 | const elements = document.querySelectorAll('*'); 18 | for (let i = 0; i < elements.length; i++) { 19 | const target = elements[i]; 20 | if ((target.tagName === 'STYLE' || target.tagName === 'SCRIPT') && target.parentNode) { 21 | target.parentNode.removeChild(target); 22 | } 23 | target.removeAttribute("style"); 24 | } 25 | 26 | const linksStyles = document.querySelectorAll('link[rel="stylesheet"]'); 27 | for (let i = 0; i < linksStyles.length; i++) { 28 | const element = linksStyles[i]; 29 | if (element.parentNode) { 30 | element.parentNode.removeChild(element); 31 | } 32 | } 33 | }); 34 | } 35 | }); 36 | 37 | 38 | const imageInterceptor = dynamicRender.interceptor({ 39 | name: 'Image Interceptor', 40 | handler: (req, respond) => { 41 | const url = req.url(); 42 | if (url.endsWith('png')) { 43 | respond({ 44 | body: placeholderPng, 45 | contentType: 'image/png' 46 | }) 47 | } else if (url.endsWith('jpg')) { 48 | respond({ 49 | body: placeholderJpg, 50 | contentType: 'image/jpeg' 51 | }) 52 | } 53 | } 54 | }); 55 | 56 | const xhrInterceptor = dynamicRender.interceptor({ 57 | name: 'Xhr Interceptor', 58 | handler: (req, respond, abort) => { 59 | const url = req.url(); 60 | if (url.includes('reviewIds')) { 61 | respond({ 62 | body: '{"reviewIdAndLikeCountMap":{},"likedReviewIds":[]}' 63 | }) 64 | } 65 | } 66 | }); 67 | 68 | const cssInterceptor = dynamicRender.interceptor({ 69 | name: 'Css Interceptor', 70 | handler: (req, respond) => { 71 | const url = req.url(); 72 | if (url.endsWith('css')) { 73 | respond({ 74 | body: '', 75 | }) 76 | } 77 | } 78 | }); 79 | 80 | const jsInterceptor = dynamicRender.interceptor({ 81 | name: 'Script Interceptor', 82 | handler: (req, respond) => { 83 | const url = req.url(); 84 | const excludedPatterns = [ 85 | 'webpush', 86 | 'sw.js', 87 | 'delphoi', 88 | 'gtm.service', 89 | 'nr-', 90 | 'enhanced.bundle' 91 | ]; 92 | if (url.endsWith('js') && excludedPatterns.some(p => url.includes(p))) { 93 | respond({ 94 | body: '', 95 | }) 96 | } 97 | } 98 | }); 99 | 100 | const lazyImageReplacer = dynamicRender.hook({ 101 | name: 'Replace Lazy Images', 102 | handler: (async page => { 103 | await page.evaluate(() => { 104 | const lazyImages = document.querySelectorAll('img[lazy]'); 105 | for (let i = 0; i < lazyImages.length; i++) { 106 | const element = lazyImages[i]; 107 | const lazySource = element.getAttribute('lazy'); 108 | 109 | if (lazySource) { 110 | element.setAttribute('src', lazySource) 111 | } 112 | } 113 | }); 114 | }) 115 | }); 116 | 117 | 118 | const productDetailPage = dynamicRender.page({ 119 | name: 'product-detail', 120 | hooks: [clearCss, lazyImageReplacer], 121 | interceptors: [jsInterceptor, imageInterceptor, cssInterceptor, xhrInterceptor], 122 | matcher: '/*' 123 | }); 124 | 125 | class CachePlugin implements Plugin { 126 | private cache: Map = new Map(); 127 | 128 | async onBeforeStart(){ 129 | console.log('Make some connections'); 130 | } 131 | 132 | async onBeforeRender(page: Page, url: string){ 133 | const existing = this.cache.get(url); 134 | 135 | if(existing){ 136 | return existing; 137 | } 138 | } 139 | 140 | async onAfterRender(page: Page, url: string, renderResult: RenderResult){ 141 | this.cache.set(url, renderResult); 142 | } 143 | } 144 | 145 | 146 | 147 | dynamicRender.application('mobile-web', { 148 | pages: [productDetailPage], 149 | origin: 'https://m.trendyol.com', 150 | plugins: [] 151 | }); 152 | 153 | const config = { 154 | puppeteer: { 155 | headless: false, 156 | ignoreHTTPSErrors: true, 157 | devtools: true, 158 | }, 159 | port: 8080 160 | }; 161 | 162 | dynamicRender 163 | .start(config) 164 | .then(port => { 165 | console.log(`Prerender listening on ${port}`); 166 | }); 167 | -------------------------------------------------------------------------------- /demo/jpg_placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trendyol/dynamic-render/dbaec32c31f0f2d9cb60c24a5d7566f7f5ff52b3/demo/jpg_placeholder -------------------------------------------------------------------------------- /demo/png_placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trendyol/dynamic-render/dbaec32c31f0f2d9cb60c24a5d7566f7f5ff52b3/demo/png_placeholder -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | modulePathIgnorePatterns: ["helpers"], 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | "src/**/*.ts" 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic-render", 3 | "version": "1.5.2", 4 | "description": "Dynamic Render is a headless Chrome rendering solution designed to render & serialise web pages on the fly.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "./node_modules/.bin/jest --coverage", 8 | "test:watch": "./node_modules/.bin/jest --coverage --watchAll", 9 | "build": "tsc" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/Trendyol/dynamic-render" 14 | }, 15 | "author": "ahmetcanguven44@gmail.com", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "@types/chai": "^4.1.7", 19 | "@types/faker": "^4.1.5", 20 | "@types/jest": "^24.0.15", 21 | "@types/node": "^12.6.1", 22 | "@types/puppeteer": "^1.12.4", 23 | "@types/sinon": "^7.5.1", 24 | "chai": "^4.2.0", 25 | "faker": "^4.1.0", 26 | "jest": "^24.8.0", 27 | "sinon": "^7.3.2", 28 | "ts-jest": "^24.0.2", 29 | "typescript": "^3.5.3" 30 | }, 31 | "dependencies": { 32 | "@types/express": "^4.17.0", 33 | "express": "^4.17.1", 34 | "iltorb": "^2.4.3", 35 | "node-zopfli-es": "^1.0.3", 36 | "puppeteer": "^1.18.1", 37 | "shrink-ray-current": "^4.0.0", 38 | "ts-node": "^8.3.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/application.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "./page"; 2 | import {EmulateOptions} from "puppeteer"; 3 | import express from "express"; 4 | import {Plugin} from "./types"; 5 | 6 | 7 | interface ApplicationConfig { 8 | origin: string; 9 | pages: Page[]; 10 | emulateOptions?: EmulateOptions; 11 | plugins?: Plugin[]; 12 | } 13 | 14 | interface ApplicationRequest extends express.Request { 15 | application?: { 16 | origin: string; 17 | emulateOptions?: EmulateOptions 18 | } 19 | } 20 | 21 | class Application { 22 | configuration: ApplicationConfig; 23 | router: express.Router; 24 | 25 | constructor(configuration: ApplicationConfig) { 26 | this.configuration = configuration; 27 | this.router = express.Router(); 28 | 29 | this.applicationInfoMiddleware = this.applicationInfoMiddleware.bind(this); 30 | this.handleStatus = this.handleStatus.bind(this); 31 | } 32 | 33 | async init() { 34 | this.router.use(this.applicationInfoMiddleware); 35 | 36 | this.configuration.pages.forEach(page => { 37 | this.router.get(page.configuration.matcher, page.handle); 38 | }); 39 | 40 | if (Array.isArray(this.configuration.plugins)) { 41 | this.configuration.pages.forEach(page => { 42 | page.plugins = this.configuration.plugins!; 43 | }); 44 | 45 | await Promise.all(this.configuration.plugins.map(async plugin => { 46 | if (plugin.onBeforeStart) { 47 | return plugin.onBeforeStart(); 48 | } 49 | })); 50 | } 51 | } 52 | 53 | toJSON() { 54 | return { 55 | pages: this.configuration.pages, 56 | emulateOptions: this.configuration.emulateOptions 57 | } 58 | } 59 | 60 | applicationInfoMiddleware(req: ApplicationRequest, res: express.Response, next: express.NextFunction) { 61 | req.application = { 62 | origin: this.configuration.origin, 63 | emulateOptions: this.configuration.emulateOptions 64 | }; 65 | next(); 66 | } 67 | 68 | handleStatus(req: express.Request, res: express.Response) { 69 | res.json(this.configuration); 70 | } 71 | } 72 | 73 | export { 74 | ApplicationRequest, 75 | ApplicationConfig, 76 | Application 77 | } 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/dynamic-render.ts: -------------------------------------------------------------------------------- 1 | import {Server, ServerConfiguration} from "./server"; 2 | import {Engine, InitialRenderProps} from "./engine"; 3 | import {Page, PageSettings} from "./page"; 4 | import {Application, ApplicationConfig} from "./application"; 5 | import {Hook, HookConfiguration} from "./hook"; 6 | import {Interceptor, InterceptorConfiguration} from "./interceptor"; 7 | import express from "express"; 8 | import {LaunchOptions} from 'puppeteer'; 9 | 10 | 11 | interface PrerenderDefaultConfiguration extends ServerConfiguration { 12 | puppeteer: Partial 13 | } 14 | 15 | interface WorkerRenderOptions { 16 | page: string; 17 | application: string; 18 | path: string; 19 | initial?: InitialRenderProps; 20 | } 21 | 22 | const defaultConfiguration = { 23 | port: 8080, 24 | puppeteer: { 25 | headless: true, 26 | ignoreHTTPSErrors: true, 27 | devtools: false 28 | }, 29 | }; 30 | 31 | class DynamicRender { 32 | applications: Map = new Map(); 33 | configuration: PrerenderDefaultConfiguration; 34 | private server: Server; 35 | 36 | private readonly engine: Engine; 37 | 38 | constructor( 39 | server: Server, 40 | renderer: Engine, 41 | ) { 42 | this.configuration = defaultConfiguration; 43 | this.server = server; 44 | this.engine = renderer; 45 | 46 | this.server.register('/', 'get', this.status.bind(this)); 47 | this.renderAsWorker = this.renderAsWorker.bind(this); 48 | } 49 | 50 | async start(configuration?: PrerenderDefaultConfiguration) { 51 | this.configuration = { 52 | ...defaultConfiguration, 53 | ...configuration, 54 | }; 55 | await this.engine.init(this.configuration.puppeteer); 56 | await this.registerApplications(); 57 | return this.server.listen(this.configuration.port); 58 | } 59 | 60 | async startAsWorker(configuration?: PrerenderDefaultConfiguration) { 61 | this.configuration = { 62 | ...defaultConfiguration, 63 | ...configuration, 64 | }; 65 | await this.engine.init(this.configuration.puppeteer); 66 | } 67 | 68 | renderAsWorker(workerParams: WorkerRenderOptions) { 69 | const application = this.applications.get(workerParams.application); 70 | if (!application) return; 71 | 72 | const page = application.configuration.pages.find(page => page.configuration.name === workerParams.page); 73 | if (!page) return; 74 | 75 | return page.handleAsWorker(application.configuration.origin, workerParams.path, workerParams.initial) 76 | } 77 | 78 | hook(configuration: HookConfiguration): Hook { 79 | return new Hook(configuration); 80 | } 81 | 82 | interceptor(configuration: InterceptorConfiguration): Interceptor { 83 | return new Interceptor(configuration); 84 | } 85 | 86 | page(pageSettings: PageSettings): Page { 87 | return new Page(pageSettings, this.engine); 88 | } 89 | 90 | application(name: string, configuration: ApplicationConfig) { 91 | this.applications.set(name, new Application(configuration)); 92 | } 93 | 94 | status(_: express.Request, res: express.Response) { 95 | const applications: { [key: string]: any } = {}; 96 | 97 | this.applications.forEach((val, key) => { 98 | applications[key] = val.toJSON() 99 | }); 100 | 101 | res.json(applications); 102 | } 103 | 104 | private async registerApplications() { 105 | for (let [name, application] of this.applications.entries()) { 106 | await application.init(); 107 | this.server.router(`/render/${name}`, application.router); 108 | } 109 | } 110 | } 111 | 112 | 113 | export { 114 | WorkerRenderOptions, 115 | PrerenderDefaultConfiguration, 116 | DynamicRender 117 | } 118 | 119 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, {Browser, EmulateOptions, LaunchOptions, LoadEvent} from "puppeteer"; 2 | import {Interceptor} from "./interceptor"; 3 | import {Hook} from "./hook"; 4 | import {ResponseCache} from "./response-cache"; 5 | 6 | 7 | interface RenderResult { 8 | status: number, 9 | html?: string, 10 | headers?: Record 11 | } 12 | 13 | interface CustomPage extends puppeteer.Page { 14 | redirect?: puppeteer.Response 15 | } 16 | 17 | interface InitialRenderProps { 18 | html: string, 19 | statusCode: number | string, 20 | headers: Record 21 | } 22 | 23 | interface RenderOptions { 24 | emulateOptions: EmulateOptions, 25 | url: string, 26 | interceptors: Interceptor[], 27 | hooks: Hook[], 28 | waitMethod: LoadEvent, 29 | followRedirects: boolean, 30 | initial?: InitialRenderProps 31 | } 32 | 33 | class Engine { 34 | private browser!: Browser; 35 | private responseCache: ResponseCache; 36 | 37 | 38 | constructor( 39 | responseCache: ResponseCache, 40 | ) { 41 | this.responseCache = responseCache; 42 | this.handleInterceptors = this.handleInterceptors.bind(this); 43 | this.onResponse = this.onResponse.bind(this); 44 | this.onRequest = this.onRequest.bind(this); 45 | this.init = this.init.bind(this); 46 | } 47 | 48 | async init(config?: Partial) { 49 | this.browser = await puppeteer.launch({ 50 | headless: true, 51 | ignoreHTTPSErrors: true, 52 | devtools: false, 53 | args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--disable-extensions'], 54 | ...config, 55 | }); 56 | 57 | this.browser.on("disconnected", this.init); 58 | } 59 | 60 | async createPage(emulateOptions: EmulateOptions, interceptors: Interceptor[], followRedirects: boolean, initial?: InitialRenderProps): Promise { 61 | const browserPage = await this.browser.newPage(); 62 | await browserPage.emulate(emulateOptions); 63 | await (browserPage as any)._client.send('Network.setBypassServiceWorker', {bypass: true}); 64 | await (browserPage as any)._client.send('Network.setCacheDisabled', { 65 | cacheDisabled: false 66 | }); 67 | await browserPage.setRequestInterception(true); 68 | 69 | browserPage.on('request', (request) => { 70 | if (initial && (request.resourceType()) === 'document' && request.method() === "GET") { 71 | return request.respond({ 72 | body: initial.html, 73 | status: +initial.statusCode, 74 | headers: initial.headers 75 | }); 76 | } 77 | 78 | this.onRequest(request, interceptors, browserPage, followRedirects) 79 | }); 80 | browserPage.on('response', this.onResponse); 81 | return browserPage; 82 | } 83 | 84 | async render(options: RenderOptions) { 85 | let browserPage: CustomPage; 86 | 87 | const renderResult: RenderResult = { 88 | status: 503, 89 | html: '' 90 | }; 91 | 92 | try { 93 | browserPage = await this.createPage(options.emulateOptions, options.interceptors, options.followRedirects, options.initial); 94 | } catch (error) { 95 | return renderResult; 96 | } 97 | 98 | try { 99 | const navigationResult = await browserPage.goto(options.url, {waitUntil: options.waitMethod}); 100 | 101 | if (navigationResult) { 102 | if (typeof options.hooks != "undefined" && options.hooks.length > 0) { 103 | for (const hook of options.hooks) await hook.handle(browserPage); 104 | } 105 | const pageContent = await browserPage.content(); 106 | renderResult.status = navigationResult.status(); 107 | renderResult.html = pageContent; 108 | } 109 | } catch (e) { 110 | if (options.followRedirects && browserPage.redirect) { 111 | const redirectRequest = browserPage.redirect; 112 | const headers = redirectRequest.headers(); 113 | const status = redirectRequest.status(); 114 | const base = new URL(options.url); 115 | const redirection = new URL(headers.location, base.origin); 116 | redirection.searchParams.delete("dr"); 117 | headers.location = redirection.href; 118 | renderResult.status = status; 119 | renderResult.headers = headers; 120 | } 121 | } 122 | 123 | await browserPage.close(); 124 | 125 | return renderResult; 126 | } 127 | 128 | async handleInterceptors(interceptors: Interceptor[], request: puppeteer.Request) { 129 | let handled = false; 130 | let i = 0; 131 | 132 | while (!handled && i < interceptors.length) { 133 | interceptors[i++].handle(request, (options) => { 134 | request.respond(options); 135 | handled = true; 136 | }, () => { 137 | request.abort('blockedbyclient'); 138 | handled = true; 139 | }); 140 | } 141 | 142 | if (!handled) { 143 | await request.continue(); 144 | } 145 | } 146 | 147 | async onResponse(response: puppeteer.Response) { 148 | await this.responseCache.setCache(response); 149 | } 150 | 151 | async onRequest(request: puppeteer.Request, interceptors: Interceptor[], browserPage: CustomPage, followRedirects: boolean) { 152 | if (followRedirects && request.isNavigationRequest() && request.redirectChain().length && request.resourceType() === 'document') { 153 | (browserPage.redirect as any) = request.redirectChain()[0].response(); 154 | return request.abort() 155 | } 156 | 157 | if (await this.responseCache.request(request)) return; 158 | 159 | 160 | if (typeof interceptors !== "undefined" && interceptors.length > 0) { 161 | this.handleInterceptors(interceptors, request); 162 | } else { 163 | return request.continue(); 164 | } 165 | } 166 | } 167 | 168 | export { 169 | InitialRenderProps, 170 | RenderResult, 171 | Engine 172 | } 173 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "puppeteer"; 2 | 3 | type HookHandler = (page: Page) => Promise; 4 | 5 | interface HookConfiguration { 6 | name: string; 7 | handler: HookHandler 8 | } 9 | 10 | class Hook { 11 | name: string; 12 | private readonly handler: HookHandler; 13 | 14 | constructor(configuration: HookConfiguration){ 15 | this.handler = configuration.handler; 16 | this.name = configuration.name; 17 | 18 | this.handle = this.handle.bind(this); 19 | } 20 | 21 | 22 | async handle(page: Page){ 23 | await this.handler(page); 24 | } 25 | } 26 | 27 | export { 28 | HookHandler, 29 | HookConfiguration, 30 | Hook 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Server} from "./server"; 2 | import {Engine} from "./engine"; 3 | import {DynamicRender} from "./dynamic-render"; 4 | import {ResponseCache} from "./response-cache"; 5 | 6 | 7 | const responseCache = new ResponseCache(); 8 | const server = new Server(); 9 | const engine = new Engine(responseCache); 10 | 11 | 12 | export = new DynamicRender(server, engine); 13 | -------------------------------------------------------------------------------- /src/interceptor.ts: -------------------------------------------------------------------------------- 1 | import {Request, RespondOptions} from "puppeteer"; 2 | 3 | type InterceptorHandler = (req: Request, respond: (options: RespondOptions) => void, abort: () => void) => void; 4 | 5 | interface InterceptorConfiguration { 6 | handler: InterceptorHandler, 7 | name: string 8 | } 9 | 10 | class Interceptor { 11 | name: string; 12 | private readonly handler: InterceptorHandler; 13 | 14 | constructor(configuration: InterceptorConfiguration) { 15 | this.handler = configuration.handler; 16 | this.name = configuration.name; 17 | 18 | this.handle = this.handle.bind(this); 19 | } 20 | 21 | handle(req: Request, respond: (options: RespondOptions) => void, block: () => void) { 22 | this.handler(req, respond, block); 23 | } 24 | } 25 | 26 | export { 27 | InterceptorHandler, 28 | InterceptorConfiguration, 29 | Interceptor 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/page.ts: -------------------------------------------------------------------------------- 1 | import {EmulateOptions, LoadEvent} from "puppeteer"; 2 | import {Hook} from "./hook"; 3 | import express from "express"; 4 | import {Interceptor} from "./interceptor"; 5 | import {ApplicationRequest} from "./application"; 6 | import {Engine, InitialRenderProps, RenderResult} from "./engine"; 7 | import {Omit} from "yargs"; 8 | import {Plugin} from "./types"; 9 | 10 | interface PageSettings { 11 | name: string; 12 | hooks?: Hook[]; 13 | interceptors?: Interceptor[]; 14 | matcher: string | RegExp | string[] | RegExp[]; 15 | emulateOptions?: EmulateOptions; 16 | waitMethod?: LoadEvent; 17 | cacheDurationSeconds?: number; 18 | query?: object; 19 | followRedirects?: boolean; 20 | } 21 | 22 | const defaultPageSettings: Omit, "matcher" | "name"> = { 23 | hooks: [], 24 | interceptors: [], 25 | cacheDurationSeconds: 0, 26 | emulateOptions: { 27 | userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', 28 | viewport: { 29 | width: 414, 30 | height: 736, 31 | deviceScaleFactor: 3, 32 | isMobile: true, 33 | hasTouch: true, 34 | isLandscape: false 35 | } 36 | }, 37 | waitMethod: "load", 38 | query: {}, 39 | followRedirects: true, 40 | }; 41 | 42 | 43 | class Page { 44 | readonly configuration: Required; 45 | public plugins: Plugin[]; 46 | private readonly engine: Engine; 47 | 48 | constructor( 49 | configuration: PageSettings, 50 | engine: Engine, 51 | plugins: Plugin[] = [] 52 | ) { 53 | 54 | this.configuration = { 55 | ...defaultPageSettings, 56 | ...configuration 57 | }; 58 | 59 | this.plugins = plugins; 60 | this.handle = this.handle.bind(this); 61 | this.engine = engine; 62 | } 63 | 64 | private convertRequestToUrl(origin: string, path: string) { 65 | const url = new URL(`${origin}${path}`); 66 | 67 | for (const [key, value] of Object.entries(this.configuration.query)) 68 | url.searchParams.append(key, value); 69 | 70 | return url.toString(); 71 | } 72 | 73 | async handleAsWorker(origin: string, path: string, initial?: InitialRenderProps) { 74 | const url = this.convertRequestToUrl(origin, path); 75 | 76 | return this.engine.render({ 77 | emulateOptions: this.configuration.emulateOptions, 78 | url: url, 79 | interceptors: this.configuration.interceptors, 80 | hooks: this.configuration.hooks, 81 | waitMethod: this.configuration.waitMethod, 82 | followRedirects: this.configuration.followRedirects, 83 | initial 84 | }); 85 | } 86 | 87 | async handle(req: ApplicationRequest, res: express.Response) { 88 | const url = this.convertRequestToUrl(req.application!.origin, req.url); 89 | 90 | if (await this.onBeforeRender(this, req, res)) return; 91 | 92 | const content = await this.engine.render({ 93 | emulateOptions: this.configuration.emulateOptions, 94 | url: url, 95 | interceptors: this.configuration.interceptors, 96 | hooks: this.configuration.hooks, 97 | waitMethod: this.configuration.waitMethod, 98 | followRedirects: this.configuration.followRedirects 99 | }); 100 | 101 | await this.onAfterRender(this, req, res, content); 102 | 103 | this.handleRenderResponse(content, res); 104 | } 105 | 106 | private async onBeforeRender(page: Page, req: express.Request, res: express.Response) { 107 | for (let plugin of this.plugins) { 108 | if (plugin.onBeforeRender) { 109 | const pluginResponse = await plugin.onBeforeRender(page, req); 110 | 111 | if (pluginResponse) { 112 | return this.handleRenderResponse(pluginResponse, res); 113 | } 114 | } 115 | } 116 | } 117 | 118 | private async onAfterRender(page: Page, req: express.Request, res: express.Response, content?: RenderResult) { 119 | await Promise.all(this.plugins.map(async plugin => { 120 | if (plugin.onAfterRender && content) { 121 | return plugin.onAfterRender(page, req, content, res); 122 | } 123 | })) 124 | } 125 | 126 | private handleRenderResponse(content: RenderResult, res: express.Response) { 127 | if (content.status === 200 && this.configuration.cacheDurationSeconds) { 128 | res.set('cache-control', `max-age=${this.configuration.cacheDurationSeconds}, public`); 129 | } 130 | 131 | if (content.headers && content.headers.location) { 132 | res 133 | .set('location', content.headers.location) 134 | .status(content.status) 135 | .end(); 136 | } else { 137 | res 138 | .status(content.status) 139 | .send(content.html); 140 | } 141 | 142 | return content; 143 | } 144 | 145 | toJSON() { 146 | return { 147 | matcher: this.configuration.matcher, 148 | interceptors: this.configuration.interceptors.map(i => i.name), 149 | hooks: this.configuration.hooks.map(i => i.name), 150 | waitMethod: this.configuration.waitMethod, 151 | emulateOptions: this.configuration.emulateOptions, 152 | query: this.configuration.query, 153 | followRedirects: this.configuration.followRedirects, 154 | } 155 | } 156 | } 157 | 158 | export { 159 | PageSettings, 160 | Page 161 | } 162 | -------------------------------------------------------------------------------- /src/response-cache.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from "puppeteer"; 2 | 3 | interface ResourceCacheContent { 4 | status: number; 5 | headers: Record; 6 | body: Buffer; 7 | t: NodeJS.Timeout; 8 | } 9 | 10 | class ResponseCache { 11 | cache: Map = new Map(); 12 | static validExtensions = ['js', 'css']; 13 | 14 | async setCache(response: puppeteer.Response) { 15 | const url = response.url(); 16 | 17 | if (!url || !ResponseCache.validExtensions.some(extension => url.endsWith(extension))) { 18 | return; 19 | } 20 | 21 | const headers = response.headers(); 22 | const status = response.status(); 23 | const maxAge = this.getMaxAge(headers['cache-control']); 24 | const inCache = this.cache.get(url); 25 | 26 | if (maxAge && !inCache) { 27 | let buffer; 28 | try { 29 | buffer = await response.buffer(); 30 | } catch (error) { 31 | return; 32 | } 33 | 34 | this.cache.set(url, { 35 | status, 36 | headers, 37 | body: buffer, 38 | t: setTimeout(() => { 39 | this.cache.delete(url); 40 | }, Math.min(maxAge * 1000, 2147483647)) 41 | }); 42 | } 43 | } 44 | 45 | async request(request: puppeteer.Request) { 46 | const url = request.url(); 47 | if (!url || !ResponseCache.validExtensions.some(extension => url.endsWith(extension))) { 48 | return false; 49 | } 50 | 51 | const cacheEntry = this.cache.get(url); 52 | 53 | if (cacheEntry) { 54 | await request.respond(cacheEntry); 55 | return true; 56 | } else { 57 | return false; 58 | } 59 | } 60 | 61 | private getMaxAge(headerString: string | undefined): number | null { 62 | if (!headerString) return null; 63 | 64 | const maxAgeMatch = headerString.match(/max-age=(\d+)/i); 65 | 66 | if (!maxAgeMatch) return null; 67 | 68 | return +maxAgeMatch[1]; 69 | } 70 | } 71 | 72 | export { 73 | ResponseCache 74 | } 75 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express, {Express, Request, Response, NextFunction} from "express"; 2 | import shrinkRayCurrent from "shrink-ray-current"; 3 | 4 | interface ServerConfiguration { 5 | port: number; 6 | } 7 | 8 | const setDynamicRenderHeader = (req: Request, res: Response, next: NextFunction) => { 9 | res.setHeader('x-powered-by', 'dynamic-rendering'); 10 | next(); 11 | } 12 | 13 | class Server { 14 | readonly app: Express; 15 | 16 | constructor() { 17 | this.app = express(); 18 | 19 | this.app.use(express.json()); 20 | this.app.use(shrinkRayCurrent()); 21 | this.app.use(setDynamicRenderHeader); 22 | } 23 | 24 | listen(port: number) { 25 | return new Promise(resolve => { 26 | console.log('Server call'); 27 | this.app.listen(port, () => { 28 | resolve(port); 29 | }); 30 | }); 31 | } 32 | 33 | register(path: string, method: string, handler: express.RequestHandler) { 34 | (this.app as any)[method](path, handler); 35 | } 36 | 37 | router(path: string, router: express.Router) { 38 | this.app.use(path, router); 39 | } 40 | } 41 | 42 | export { 43 | setDynamicRenderHeader, 44 | ServerConfiguration, 45 | Server 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "./page"; 2 | import {RenderResult} from "./engine"; 3 | import express from "express"; 4 | 5 | interface Plugin { 6 | onBeforeStart?: () => Promise; 7 | 8 | onBeforeRender?: (page: Page, request: express.Request) => Promise; 9 | 10 | onAfterRender?: (page: Page, request: express.Request, renderResult: RenderResult, res: express.Response) => Promise; 11 | } 12 | 13 | type PluginEvents = 'onAfterRender' | 'onBeforeRender'; 14 | 15 | export { 16 | PluginEvents, 17 | Plugin 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "downlevelIteration": true 9 | }, 10 | "exclude": [ 11 | "demo", 12 | "__tests__" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------