├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prerender.ts ├── serverless.yml ├── src ├── driver.ts ├── index.ts ├── interfaces │ ├── lambda-proxy.ts │ └── serverless-chrome.d.ts ├── prerender.ts ├── serverless-chrome.ts ├── strategies │ ├── base.ts │ ├── index.ts │ ├── prerender.ts │ └── s3-cache.ts └── util.ts ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # package directories 61 | node_modules 62 | jspm_packages 63 | 64 | # Serverless directories 65 | .serverless 66 | 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright © 2018 MooYeol Prescott Lee, http://debug.so 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the “Software”), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-prerender 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 4 | 5 | Serverless implementation of Prerender service 6 | 7 | 8 | ## Features 9 | 10 | - Render Single Page Application for Crawlers 11 | - Support caching via [built-in S3CacheStrategy](https://github.com/mooyoul/serverless-prerender/blob/master/src/strategies/s3-cache.ts) - [see usage](https://github.com/mooyoul/serverless-prerender/blob/d55d1b4971dc9ae057197ea9d49f02dcb4290bca/src/index.ts#L18-L26) 12 | - Compatible with [Prerender](https://github.com/prerender/prerender) (respect prerender-specific meta elements like `prerender-status-code` or `prerender-header`) 13 | - Customizable render Strategy via [built-in StrategyLifeCycle](https://github.com/mooyoul/serverless-prerender/blob/master/src/strategies/base.ts) 14 | 15 | 16 | ## Getting Started 17 | 18 | ```bash 19 | $ serverless install --url https://github.com/mooyoul/serverless-prerender 20 | $ npm install 21 | $ serverless deploy 22 | ``` 23 | 24 | 25 | ## Debugging 26 | 27 | To see debug logs, Please set `DEBUG` environment variable to `serverless-prerender:*`. 28 | or you can see my comments on [serverless.yml](https://github.com/mooyoul/serverless-prerender/blob/e7c45c5b2956f08449e6fec5bf357ea3ed489586/serverless.yml#L13-L15) 29 | 30 | 31 | ## Todo 32 | 33 | - [ ] Update Documentations 34 | - [ ] Add tests 35 | - [ ] Add nginx configuration example 36 | - [ ] Add Lambda@Edge middleware to handle actual crawler requests 37 | 38 | 39 | ## Thanks 40 | 41 | - [adieuadieu/serverless-chrome](https://github.com/adieuadieu/serverless-chrome) 42 | - Marco Lüthy did a great work. He created serverless-chrome project. so i was able to make this project. Thank you! 43 | - Teammates 44 | - Experiences with various serverless related projects helped me a lot when making this project. 45 | - Take a look around our serverless related projects! 46 | - [balmbees/corgi](https://github.com/balmbees/corgi) - Web Framework for AWS Lambda, Typescript based, built-in router, swagger support 47 | - [balmbees/dynamo-typeorm](https://github.com/balmbees/dynamo-typeorm) - Object Data Mapper (ODM) for AWS DynamoDB, Typescript based, built-in GSI/DAX support 48 | 49 | 50 | ## License 51 | 52 | [MIT](LICENSE) 53 | 54 | See full license on [mooyoul.mit-license.org](http://mooyoul.mit-license.org/) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-prerender", 3 | "version": "1.0.0", 4 | "description": "Serverless based lambda function to prerender Single Page Applications", 5 | "main": "handler.js", 6 | "scripts": { 7 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/mooyoul/serverless-prerender.git" 13 | }, 14 | "keywords": [ 15 | "serverless", 16 | "prerender", 17 | "seo" 18 | ], 19 | "author": "MooYeol Prescott Lee ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/mooyoul/serverless-prerender/issues" 23 | }, 24 | "homepage": "https://github.com/mooyoul/serverless-prerender#readme", 25 | "devDependencies": { 26 | "@types/bluebird": "^3.5.20", 27 | "@types/cheerio": "^0.22.7", 28 | "@types/debug": "0.0.30", 29 | "@types/node": "^9.4.5", 30 | "@types/puppeteer": "0.13.10", 31 | "@types/request": "^2.47.0", 32 | "@types/valid-url": "^1.0.2", 33 | "serverless": "^1.26.0", 34 | "serverless-webpack": "^4.3.0", 35 | "ts-loader": "^3.5.0", 36 | "tslint": "^5.9.1", 37 | "typescript": "^2.7.1", 38 | "webpack": "^3.11.0" 39 | }, 40 | "dependencies": { 41 | "@serverless-chrome/lambda": "1.0.0-36", 42 | "aws-sdk": "^2.194.0", 43 | "bluebird": "^3.5.1", 44 | "cheerio": "^1.0.0-rc.2", 45 | "debug": "^3.1.0", 46 | "puppeteer": "0.13.0", 47 | "request": "^2.83.0", 48 | "valid-url": "^1.0.9" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /prerender.ts: -------------------------------------------------------------------------------- 1 | // HACK: hacky workaround for "No matching handler found for 'handlerName'." Error 2 | // serverless refuses deploy if there are no "Matching Handlers". 3 | export * from "./src"; 4 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-prerender 2 | 3 | plugins: 4 | - serverless-webpack 5 | 6 | custom: 7 | webpackIncludeModules: true 8 | 9 | provider: 10 | name: aws 11 | runtime: nodejs6.10 12 | stage: prod 13 | # Uncomment below block if you want to see debug logs 14 | # environment: 15 | # DEBUG: "*,-puppeteer*" 16 | # 17 | # Uncomment below line and edit bucket name if you want to use s3 cache 18 | # iamRoleStatements: 19 | # - Effect: "Allow" 20 | # Action: 21 | # - "s3:GetObject" 22 | # - "s3:PutObject" 23 | # Resource: "arn:aws:s3:::YOUR-BUCKET-NAME/*" 24 | 25 | functions: 26 | prerender: 27 | handler: prerender.handler 28 | timeout: 30 29 | # Use Higher memory if you need more faster render performance 30 | memorySize: 1536 31 | events: 32 | - http: 33 | path: /{proxy+} 34 | method: get 35 | 36 | # Uncomment below line if you want to create s3 bucket 37 | #resources: 38 | # Resources: 39 | # S3BucketPrerender: 40 | # Type: AWS::S3::Bucket 41 | # Properties: 42 | # BucketName: YOUR-BUCKET-NAME 43 | -------------------------------------------------------------------------------- /src/driver.ts: -------------------------------------------------------------------------------- 1 | import * as BbPromise from "bluebird"; 2 | import * as debug from "debug"; 3 | import * as puppeteer from "puppeteer"; 4 | import * as request from "request"; 5 | 6 | import { launchChrome } from "./serverless-chrome"; 7 | 8 | export interface DriverOptions { 9 | disableServerlessChrome?: boolean; 10 | userAgent?: string; 11 | } 12 | 13 | export class Driver { 14 | private readonly LOG_TAG = "serverless-prerender:driver"; 15 | 16 | // @todo Inject ServerlessPrerender/VERSION token 17 | private readonly userAgent?: string; 18 | private readonly disableServerlessChrome: boolean; 19 | 20 | private browser: puppeteer.Browser | null = null; 21 | private slsChrome: { 22 | kill: () => Promise; 23 | } | null = null; 24 | 25 | private log = debug(this.LOG_TAG); 26 | 27 | constructor(options: DriverOptions) { 28 | this.disableServerlessChrome = options.disableServerlessChrome || false; 29 | this.userAgent = options.userAgent; 30 | } 31 | 32 | public async getPage(): Promise { 33 | if (!this.browser) { 34 | throw new Error("browser not found"); 35 | } 36 | 37 | this.log("creating page"); 38 | return await this.browser.newPage(); 39 | } 40 | 41 | public async shutdown() { 42 | if (this.browser) { 43 | await this.browser.close(); 44 | this.browser = null; 45 | 46 | if (!this.disableServerlessChrome && this.slsChrome) { 47 | await this.slsChrome.kill(); 48 | } 49 | } 50 | } 51 | 52 | public async launch() { 53 | if (this.browser) { 54 | this.log("running chrome instance detected, checking availability..."); 55 | 56 | try { 57 | const version = await this.browser.version(); 58 | this.log("received chrome version %s response", version); 59 | return; 60 | } catch (e) { 61 | this.log("failed to receive response from instance: ", e.stack); 62 | this.log("re-launching..."); 63 | await this.shutdown(); 64 | this.slsChrome = null; 65 | } 66 | } 67 | 68 | const ADDITIONAL_CHROME_FLAGS = [ 69 | this.userAgent ? `--user-agent="${this.userAgent}"` : "", 70 | ]; 71 | 72 | if (this.disableServerlessChrome) { // local or test environment 73 | this.log("DISABLE_SERVERLESS_CHROME is set, launching bundled chrome!"); 74 | this.browser = await puppeteer.launch({ 75 | args: ADDITIONAL_CHROME_FLAGS, 76 | }); 77 | } else { // in lambda runtime 78 | this.log("launching serverless-chrome instance"); 79 | const chrome = await launchChrome({ 80 | flags: ADDITIONAL_CHROME_FLAGS, 81 | }); 82 | this.log("chrome: ", chrome); 83 | 84 | this.log("getting debugger url from %s", chrome.url); 85 | 86 | const debuggerUrl = await this.getDebuggerUrl(chrome.url); 87 | 88 | this.log("got debugger url: ", debuggerUrl); 89 | this.browser = await puppeteer.connect({ 90 | browserWSEndpoint: debuggerUrl, 91 | }); 92 | this.slsChrome = chrome; 93 | } 94 | 95 | this.log("successfully connected"); 96 | } 97 | 98 | private getDebuggerUrl(baseUrl: string): BbPromise { 99 | return new BbPromise((resolve, reject) => { 100 | request({ 101 | method: "GET", 102 | url: `${baseUrl}/json/version`, 103 | json: true, 104 | timeout: 5000, 105 | }, (e, res, body) => { 106 | if (e) { 107 | return reject(e); 108 | } 109 | 110 | const debuggerUrl = body.webSocketDebuggerUrl; 111 | 112 | if (!debuggerUrl) { 113 | return reject(new Error("Couldn't find debugger url from response")); 114 | } 115 | 116 | resolve(debuggerUrl as string); 117 | }); 118 | }); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as BbPromise from "bluebird"; 2 | 3 | import { Driver } from "./driver"; 4 | import { Prerender } from "./prerender"; 5 | import { PrerenderStrategy, S3CacheStrategy } from "./strategies"; 6 | 7 | import { Context, Event, HandlerCallback } from "./interfaces/lambda-proxy"; 8 | 9 | const prerender = new Prerender( 10 | new Driver({ 11 | disableServerlessChrome: !process.env.AWS_EXECUTION_ENV, 12 | // userAgent: "YOUR-CUSTOM-USER-AGENT", 13 | }), 14 | ); 15 | 16 | prerender.use( 17 | // S3CacheStrategy can be optional 18 | new S3CacheStrategy({ 19 | Bucket: "YOUR-BUCKET-NAMAE", 20 | // Bucket: "YOUR-CACHE-NAME", 21 | // KeyPrefix: "KEY_PREFIX/IF/YOU/WANT", 22 | // ExpiresInSeconds: 3600, // 1h 23 | // keyMapper(url: string) { 24 | // return customUrlSerializer(url); 25 | // }, 26 | }), 27 | new PrerenderStrategy({ 28 | waitForPrerenderReady: true, 29 | stripScripts: true, 30 | // timeout: CUSTOM_NAVIGATION_TIMEOUT, 31 | }), 32 | ); 33 | 34 | export function handler(event: Event, context: Context, callback: HandlerCallback) { 35 | BbPromise.resolve( 36 | prerender.handleEvent(event /*, RENDERER_TIMEOUT */), 37 | ).asCallback(callback); 38 | } 39 | -------------------------------------------------------------------------------- /src/interfaces/lambda-proxy.ts: -------------------------------------------------------------------------------- 1 | // Took from https://github.com/balmbees/corgi 2 | 3 | // tslint:disable 4 | export interface Event { 5 | resource?: string; 6 | path: string; 7 | httpMethod: string; 8 | headers: EventHeaders; 9 | queryStringParameters?: EventQueryStringParameters; 10 | pathParameters?: EventPathParameters; 11 | stageVariables?: EventStageVariables; 12 | requestContext?: { 13 | accountId: string; 14 | resourceId: string; 15 | stage: string; 16 | requestId: string; 17 | identity: { 18 | cognitoIdentityPoolId: string; 19 | accountId: string; 20 | cognitoIdentityId: string; 21 | caller: string; 22 | apiKey: string; 23 | sourceIp: string, 24 | accessKey: string; 25 | cognitoAuthenticationType: string; 26 | cognitoAuthenticationProvider: string; 27 | userArn: string; 28 | userAgent: string; 29 | user: string; 30 | } 31 | resourcePath: string; 32 | httpMethod: string; 33 | apiId: string; 34 | }; 35 | body?: string; 36 | } 37 | 38 | export interface EventHeaders { 39 | [key: string]: string; 40 | } 41 | 42 | export interface EventQueryStringParameters { 43 | [key: string]: string; 44 | } 45 | 46 | export interface EventPathParameters { 47 | [key: string]: string; 48 | } 49 | 50 | export interface EventStageVariables { 51 | [key: string]: string; 52 | } 53 | 54 | // Response 55 | export interface Response { 56 | statusCode: number; 57 | headers: { [key: string]: string }; 58 | body: string; 59 | } 60 | 61 | export interface Context { 62 | // Properties 63 | functionName: string; 64 | functionVersion: string; 65 | invokedFunctionArn: string; 66 | memoryLimitInMB: number; 67 | awsRequestId: string; 68 | logGroupName: string; 69 | logStreamName: string; 70 | identity?: CognitoIdentity; 71 | clientContext?: ClientContext; 72 | 73 | // Functions 74 | succeed(result?: Object): void; 75 | fail(error: Error): void; 76 | done(error: Error | null, result?: Response): void; // result must be JSON.stringifyable 77 | getRemainingTimeInMillis(): number; 78 | } 79 | 80 | export interface CognitoIdentity { 81 | cognito_identity_id: string; 82 | cognito_identity_pool_id: string; 83 | } 84 | 85 | export interface ClientContext { 86 | client: ClientContextClient; 87 | Custom?: any; 88 | env: ClientContextEnv; 89 | } 90 | 91 | export interface ClientContextClient { 92 | installation_id: string; 93 | app_title: string; 94 | app_version_name: string; 95 | app_version_code: string; 96 | app_package_name: string; 97 | } 98 | 99 | export interface ClientContextEnv { 100 | platform_version: string; 101 | platform: string; 102 | make: string; 103 | model: string; 104 | locale: string; 105 | } 106 | 107 | export type HandlerCallback = (error: any, data?: any) => void; 108 | // tslint:enable 109 | -------------------------------------------------------------------------------- /src/interfaces/serverless-chrome.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@serverless-chrome/lambda" { 2 | interface LauncherOptions { 3 | flags?: string[]; 4 | } 5 | 6 | interface ChromeInstance { 7 | pid: number; 8 | port: number; 9 | url: string; 10 | kill: () => Promise; 11 | log: string; 12 | errorLog: string; 13 | pidFile: string; 14 | metaData: { 15 | launchTime: number, // as timestamp 16 | didLaunch: boolean; 17 | }; 18 | } 19 | 20 | const launchChrome: (options?: LauncherOptions) => Promise; 21 | 22 | export = launchChrome; 23 | } 24 | -------------------------------------------------------------------------------- /src/prerender.ts: -------------------------------------------------------------------------------- 1 | import * as BbPromise from "bluebird"; 2 | import * as debug from "debug"; 3 | import * as qs from "querystring"; 4 | 5 | import { Driver } from "./driver"; 6 | import { Event } from "./interfaces/lambda-proxy"; 7 | import * as Strategy from "./strategies/base"; 8 | import { anySeries, isValidUrl } from "./util"; 9 | 10 | export class Prerender { 11 | private readonly LOG_TAG = "serverless-prerender:prerender"; 12 | private readonly log = debug(this.LOG_TAG); 13 | 14 | private strategies: Strategy.StrategyLifeCycle[] = []; 15 | 16 | constructor( 17 | private driver: Driver, 18 | ) {} 19 | 20 | public use(...strategies: Strategy.StrategyLifeCycle[]) { 21 | Array.prototype.push.apply(this.strategies, strategies); 22 | } 23 | 24 | public async handleEvent(event: Event, timeout?: number) { 25 | const request = this.transformEvent(event); 26 | 27 | if (!request) { 28 | return { 29 | statusCode: 400, 30 | headers: { 31 | "content-type": "text/plain; charset=UTF-8", 32 | }, 33 | body: "Bad Request", 34 | }; 35 | } 36 | 37 | try { 38 | return await this.render(request, timeout); 39 | } catch (e) { 40 | this.log(e.stack); 41 | 42 | if (e instanceof BbPromise.TimeoutError) { 43 | return { 44 | statusCode: 504, 45 | headers: { 46 | "content-type": "text/plain; charset=UTF-8", 47 | }, 48 | body: "Gateway Timeout", 49 | }; 50 | } 51 | 52 | return { 53 | statusCode: 502, 54 | headers: { 55 | "content-type": "text/plain; charset=UTF-8", 56 | }, 57 | body: "Bad Gateway", 58 | }; 59 | } 60 | } 61 | 62 | public async render(request: Strategy.Request, timeout: number = 25000): Promise { 63 | this.log("received request %s", request.url); 64 | 65 | try { 66 | return await BbPromise.resolve((async () => { 67 | this.log("executing before hooks"); 68 | 69 | const beforeResponse = await anySeries( 70 | this.strategies.filter((s) => s.before), 71 | (s) => s.before!(request), 72 | ); 73 | 74 | if (beforeResponse) { 75 | this.log("got response from before hook"); 76 | 77 | return this.normalizeResponse(beforeResponse); 78 | } 79 | 80 | await this.driver.launch(); 81 | 82 | this.log("creating page"); 83 | const page = await this.driver.getPage(); 84 | 85 | this.log("executing setup hooks"); 86 | await BbPromise.mapSeries( 87 | this.strategies.filter((s) => s.setup), 88 | (s) => s.setup!(request, page), 89 | ); 90 | 91 | this.log("executing render hooks"); 92 | const renderResponse = await anySeries( 93 | this.strategies.filter((s) => s.render), 94 | (s) => s.render!(request, page), 95 | ); 96 | 97 | await page.close(); 98 | 99 | if (!renderResponse) { 100 | this.log("could not receive response from strategies, returning 502 bad gateway response!"); 101 | 102 | return { 103 | statusCode: 502, 104 | headers: { 105 | "content-type": "text/plain; charset=UTF-8", 106 | }, 107 | body: "Bad Gateway", 108 | }; 109 | } 110 | 111 | this.log("executing after hooks"); 112 | const afterResponse = await anySeries( 113 | this.strategies.filter((s) => s.after), 114 | (s) => s.after!(request, renderResponse), 115 | ); 116 | 117 | this.log("normalizing response"); 118 | return this.normalizeResponse(afterResponse || renderResponse); 119 | })()).timeout(timeout); 120 | } finally { 121 | this.log("cleanup..."); 122 | await this.driver.shutdown(); 123 | } 124 | } 125 | 126 | private transformEvent(event: Event): Strategy.Request | null { 127 | const urlWithoutQuery = event.path.slice(1); 128 | 129 | const builtUrl = event.queryStringParameters ? 130 | `${urlWithoutQuery}?${qs.stringify(event.queryStringParameters)}` : 131 | urlWithoutQuery; 132 | 133 | if (!isValidUrl(builtUrl)) { 134 | return null; 135 | } 136 | 137 | return { url: builtUrl }; 138 | } 139 | 140 | private normalizeResponse(response: Strategy.Response): Strategy.Response { 141 | const defaultHeaders = { 142 | "content-type": "text/html; charset=UTF-8", 143 | }; 144 | 145 | const normalizedHeaders = Object.keys(response.headers || {}).reduce((hash, key) => { 146 | hash[key.toLowerCase()] = response.headers![key]; 147 | 148 | return hash; 149 | }, Object.create(null)); 150 | 151 | return { 152 | statusCode: response.statusCode || 200, 153 | headers: { 154 | ...defaultHeaders, 155 | ...normalizedHeaders, 156 | }, 157 | body: response.body, 158 | }; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/serverless-chrome.ts: -------------------------------------------------------------------------------- 1 | // This module simply wraps exported function that came from package, 2 | // because that's impossible stubbing exported function. 3 | 4 | import * as launchChrome from "@serverless-chrome/lambda"; 5 | 6 | export { launchChrome }; 7 | -------------------------------------------------------------------------------- /src/strategies/base.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer"; 2 | 3 | export type Page = Page; 4 | 5 | export interface Request { 6 | url: string; 7 | } 8 | 9 | export interface Response { 10 | statusCode?: number; 11 | headers?: { 12 | [key: string]: string; 13 | }; 14 | body: string; 15 | } 16 | 17 | export interface StrategyLifeCycle { 18 | before?: (request: Request) => Promise; 19 | setup?: (request: Request, page: Page) => Promise; 20 | render?: (request: Request, page: Page) => Promise; 21 | after?: (request: Request, response: Response) => Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./prerender"; 2 | export * from "./s3-cache"; 3 | -------------------------------------------------------------------------------- /src/strategies/prerender.ts: -------------------------------------------------------------------------------- 1 | import * as debug from "debug"; 2 | 3 | import * as Strategy from "./base"; 4 | 5 | export interface PrerenderStrategyOptions { 6 | waitForPrerenderReady?: boolean; 7 | stripScripts?: boolean; 8 | timeout?: number; 9 | } 10 | 11 | export class PrerenderStrategy implements Strategy.StrategyLifeCycle { 12 | private readonly LOG_TAG = "serverless-prerender:PrerenderStrategy"; 13 | private readonly log = debug(this.LOG_TAG); 14 | 15 | private readonly waitForPrerenderReady: boolean; 16 | private readonly stripScripts: boolean; 17 | private readonly timeout?: number; 18 | 19 | constructor(options: PrerenderStrategyOptions = {}) { 20 | this.waitForPrerenderReady = options.waitForPrerenderReady || false; 21 | this.stripScripts = options.stripScripts || true; 22 | this.timeout = options.timeout; 23 | } 24 | 25 | public async setup(request: Strategy.Request, page: Strategy.Page) { 26 | // setup request interceptor 27 | await page.setRequestInterception(true); 28 | 29 | page.on("request", (interceptedRequest) => { 30 | switch (interceptedRequest.resourceType) { 31 | case "image": 32 | case "media": 33 | case "font": { 34 | interceptedRequest.abort(); 35 | break; 36 | } 37 | default: { 38 | interceptedRequest.continue(); 39 | break; 40 | } 41 | } 42 | }); 43 | 44 | page.on("dialog", async (dialog) => { 45 | this.log("got dialog (type: %s, message: %s)", dialog.type, dialog.message()); 46 | await dialog.dismiss(); 47 | }); 48 | } 49 | 50 | public async render(request: Strategy.Request, page: Strategy.Page) { 51 | this.log("navigating to ", request.url); 52 | 53 | await page.goto(request.url, { timeout: this.timeout }); 54 | 55 | this.log("page loaded, got window load event"); 56 | 57 | if (this.waitForPrerenderReady) { 58 | this.log("waiting for prerenderReady flag"); 59 | await page.waitForFunction("window.prerenderReady === true"); 60 | } 61 | 62 | if (this.stripScripts) { 63 | this.log("stripping scripts"); 64 | 65 | await page.$$eval("script", (scripts) => { 66 | scripts.forEach((el) => { 67 | const type = el.getAttribute("type"); 68 | 69 | if (type !== "application/ld+json") { 70 | el.remove(); 71 | } 72 | }); 73 | }); 74 | 75 | await page.$$eval("link[rel='preload']", (links) => { 76 | links.forEach((el) => { 77 | const as = el.getAttribute("as"); 78 | 79 | if (as === "script") { 80 | el.remove(); 81 | } 82 | }); 83 | }); 84 | } 85 | 86 | return { 87 | statusCode: (await this.findStatusCode(page)) || 200, 88 | headers: (await this.findHeaders(page)) || {}, 89 | body: await page.content(), 90 | }; 91 | } 92 | 93 | private async findStatusCode(page: Strategy.Page) { 94 | this.log("finding prerender-status-code meta element"); 95 | 96 | try { 97 | const content = await page.$eval( 98 | "meta[name='prerender-status-code']", 99 | (el) => el.getAttribute("content"), 100 | ) as string | null; 101 | 102 | this.log("found status code : ", content); 103 | 104 | if (!content) { 105 | return null; 106 | } 107 | 108 | return parseInt(content, 10) || null; 109 | } catch (e) { 110 | return null; 111 | } 112 | } 113 | 114 | private async findHeaders(page: Strategy.Page) { 115 | try { 116 | const headerStrings = await page.$$eval( 117 | "meta[name='prerender-header']", 118 | (els) => Array.prototype.map.call(els, (el: Element) => el.getAttribute("content")), 119 | ) as string[]; 120 | 121 | return headerStrings.reduce((hash, v) => { 122 | const [ key, value ] = v.split(":"); 123 | 124 | hash[key] = value; 125 | 126 | return hash; 127 | }, {} as { [key: string]: string }); 128 | } catch (e) { 129 | return null; 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/strategies/s3-cache.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from "aws-sdk"; 2 | import * as crypto from "crypto"; 3 | import * as debug from "debug"; 4 | import * as path from "path"; 5 | 6 | import * as Strategy from "./base"; 7 | 8 | type KeyMapper = (url: string) => string; 9 | 10 | export interface S3CacheStrategyOptions { 11 | Bucket: string; 12 | KeyPrefix?: string; 13 | ExpiresInSeconds?: number; 14 | keyMapper?: KeyMapper; 15 | } 16 | 17 | export class S3CacheStrategy implements Strategy.StrategyLifeCycle { 18 | private readonly BUCKET: string; 19 | private readonly KEY_PREFIX: string; 20 | private readonly EXPIRES_IN_MS: number; 21 | 22 | private readonly LOG_TAG = "serverless-prerender:S3CacheStrategy"; 23 | private readonly log = debug(this.LOG_TAG); 24 | 25 | private readonly s3 = new S3(); 26 | private readonly keyMapper: KeyMapper; 27 | 28 | constructor(options: S3CacheStrategyOptions) { 29 | this.BUCKET = options.Bucket; 30 | this.KEY_PREFIX = options.KeyPrefix || ""; 31 | this.EXPIRES_IN_MS = options.ExpiresInSeconds ? 32 | (options.ExpiresInSeconds * 1000) : 33 | (60 * 60 * 24 * 1000); // defaults to 1 day 34 | this.keyMapper = options.keyMapper || this.defaultKeyMapper; 35 | } 36 | 37 | public async before(request: Strategy.Request) { 38 | const key = path.join(this.KEY_PREFIX, this.keyMapper(request.url)); 39 | 40 | try { 41 | this.log("looking for cached data (%s/%s)", this.BUCKET, key); 42 | const { Body } = await this.s3.getObject({ 43 | Bucket: this.BUCKET, 44 | Key: key, 45 | }).promise(); 46 | 47 | const cached = Buffer.isBuffer(Body) ? 48 | (Body as Buffer).toString("utf8") as string : 49 | Body as string; 50 | 51 | this.log("found cache from s3, parsing response..."); 52 | 53 | return JSON.parse(cached) as Strategy.Response; 54 | } catch (e) { 55 | if (e.code === "NoSuchKey" || e.code === "AccessDenied") { 56 | this.log("there are no cached data (code: %s)", e.code); 57 | return; 58 | } 59 | 60 | throw e; 61 | } 62 | 63 | } 64 | 65 | public async after(request: Strategy.Request, response: Strategy.Response) { 66 | const key = path.join(this.KEY_PREFIX, this.keyMapper(request.url)); 67 | 68 | try { 69 | this.log("saving response to cache"); 70 | 71 | const serialized = JSON.stringify(response); 72 | 73 | await this.s3.putObject({ 74 | Bucket: this.BUCKET, 75 | Key: key, 76 | Body: serialized, 77 | Expires: new Date(Date.now() + this.EXPIRES_IN_MS), 78 | }).promise(); 79 | 80 | this.log("response saved to cache"); 81 | } catch (e) { 82 | this.log("failed to save response to cache"); 83 | this.log(e.stack); 84 | } 85 | } 86 | 87 | private defaultKeyMapper(url: string): string { 88 | return crypto.createHash("sha256") 89 | .update(url) 90 | .digest("hex"); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as validUrl from "valid-url"; 2 | 3 | export async function anySeries(items: T[], mapper: (item: T) => Promise): Promise { 4 | const clone = items.slice(); 5 | while (clone.length) { 6 | const value = await mapper(clone.shift()!); 7 | 8 | if (value) { 9 | return value; 10 | } 11 | } 12 | } 13 | 14 | export function isValidUrl(url: string): boolean { 15 | return typeof validUrl.isWebUri(url) === "string"; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "outDir": "dst", 8 | "strictNullChecks": true, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true 11 | }, 12 | "exclude": [ 13 | "**/__test__/" 14 | ], 15 | "include": [ 16 | "src/**/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "object-literal-sort-keys": false, 9 | "interface-name": [true, "never-prefix"] 10 | }, 11 | "rulesDirectory": [] 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | 4 | module.exports = { 5 | entry: './prerender', 6 | target: 'node', 7 | resolve: { 8 | alias: { 9 | // @note This alias is required if building service on Node.js > v6, 10 | // because webpack tree shaking drops built-in runtime check logic in puppeteer package 11 | "puppeteer": require.resolve("puppeteer/node6/Puppeteer") 12 | }, 13 | extensions: [ 14 | '.js', 15 | '.json', 16 | '.ts', 17 | '.tsx' 18 | ] 19 | }, 20 | output: { 21 | libraryTarget: 'commonjs', 22 | path: path.join(__dirname, '.webpack'), 23 | filename: 'prerender.js' 24 | }, 25 | module: { 26 | loaders: [ 27 | { test: /\.ts$/, loader: 'ts-loader' } 28 | ] 29 | }, 30 | externals: [ 31 | 'aws-sdk', 32 | // @note `@serverless-chrome/lambda` package has extra built chromium binary, 33 | // which is specially to be handled 34 | '@serverless-chrome/lambda' 35 | ] 36 | }; --------------------------------------------------------------------------------