├── .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 | [](https://circleci.com/gh/Trendyol/dynamic-render) [](https://codecov.io/gh/Trendyol/dynamic-render) [](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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------