├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.ts ├── babel.config.js ├── handler.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── manifest.json ├── serverless.yml ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── browser │ ├── index.css │ └── index.tsx ├── components │ ├── ConfigContext.tsx │ ├── __mocks__ │ │ └── ConfigContext.tsx │ └── useConfig.tsx ├── custom.d.ts ├── logo.svg └── server │ ├── config.tsx │ ├── html.tsx │ ├── render.tsx │ └── types.tsx ├── tsconfig.json ├── webpack.browser.config.js └── webpack.server.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "12" 8 | } 9 | } 10 | ], 11 | "@babel/preset-typescript", 12 | "@babel/preset-react" 13 | ], 14 | "plugins": [], 15 | "env": { 16 | "test": { 17 | "plugins": [] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | .env 8 | 9 | # Webpack and distribution directories 10 | .webpack 11 | dist 12 | 13 | # Other 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2020 Andre Rabold 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ♨️ serverless-react-boilerplate 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 4 | [![dependencies](https://img.shields.io/david/arabold/serverless-react-boilerplate.svg)](https://github.com/arabold/serverless-react-boilerplate) 5 | 6 | Lightweight boilerplate project to set up a React 17 web application on AWS Lambda using the Serverless Framework. 7 | 8 | ## Key Features 9 | 10 | - Universal app; server-side rendering with dynamic configuration context passed from backend to browser. 11 | - Self-contained; no additional setup steps necessary other than running `npx sls deploy`. 12 | - Lightweight; no mandatory `redux`, `react-router`, `sass`, `less` or any other 3rd party dependency for full flexibility. 13 | - React "Fast Refresh" (previously known as "Hot Reloading") using the [React Refresh Webpack Plugin](https://github.com/pmmmwh/react-refresh-webpack-plugin). 14 | - Built-in support for [code splitting](https://webpack.js.org/guides/code-splitting/) and [tree shaking](https://webpack.js.org/guides/tree-shaking/) to optimize page loading times. 15 | - Full [TypeScript](https://www.typescriptlang.org/) support using Babel 7 and Webpack 5, including custom [module resolution](https://www.typescriptlang.org/docs/handbook/module-resolution.html). 16 | - Working [Jest](https://jestjs.io/) test environment. 17 | 18 | [Looking for the plain JavaScript version of this boilerplate?](https://github.com/arabold/serverless-react-boilerplate/) 19 | 20 | ## Overview 21 | 22 | ### How Does It Work? 23 | 24 | The idea is that we use AWS Lambda to serve the dynamic part of our app, the server-side logic, and perform the server-side rendering. For all static data like images, stylesheets, and even the app's `index.tsx` that is loaded in the browser, we use an S3 bucket for public hosting. 25 | 26 | This combination makes our app fast and incredibly scalable. AWS will spin up new Lambda instances once your number of users increases, handling even the largest spikes fully automatically while incurring virtually no costs when your app isn't used. At the same time, S3 provides a robust and fast platform for your static content so you don't have to waste your own computing resources. 27 | 28 | All resources, including the S3 bucket for hosting static content, are created and configured automatically when your app is deployed the first time. You can make changes to the default setup by updating your `serverless.yml` to your linking. 29 | 30 | ### Folder Structure 31 | 32 | ``` 33 | serverless-react-boilerplate/ 34 | │ 35 | ├── public/ - Public assets which will retain their original file names and folder structure 36 | │ ├── favicon.ico - Favicon 37 | │ └── manifest.json - Web page manifest 38 | │ 39 | ├── src/ 40 | │ ├── browser/ 41 | │ │ └── ... - Client-side code running in the browser as well as during server-side rendering 42 | │ ├── components/ 43 | │ │ └── ... - React components 44 | │ ├── server/ 45 | │ │ └── ... - Server-side code running on AWS Lambda 46 | │ ├── App.tsx - The web application's root component. 47 | │ └── ... - Other files used by the application 48 | │ 49 | ├── handler.ts - AWS Lambda function handler 50 | ├── serverless.yml - Project configuration 51 | ├── babel.config.js - Babel configuration 52 | ├── jest.config.js - Jest configuration 53 | ├── webpack.browser.config.js - Webpack configuration for client-side code 54 | ├── webpack.server.config.js - Webpack configuration for the Lambda backend 55 | └── ... 56 | ``` 57 | 58 | ### Serverless 59 | 60 | The project is based on the [Serverless Framework](https://serverless.com) and makes use of several plugins: 61 | 62 | - [Webpack Plugin](https://github.com/serverless-heaven/serverless-webpack) - We use Webpack for packaging our sources. 63 | - [Offline Plugin](https://github.com/dherault/serverless-offline) - The Serverless Offline Plugin allows you to run Serverless applications locally as if they would be deployed on AWS. This is especially helpful for testing web applications and APIs without having to deploy them anywhere. 64 | - [Scripts Plugin](https://github.com/mvila/serverless-plugin-scripts#readme) - Run shell scripts as part of your Serverless workflow 65 | - [S3 Deploy Plugin](https://github.com/funkybob/serverless-s3-deploy) - Deploy files to S3 buckets. This is used for uploading static content like images and the generated `main.js`. 66 | 67 | ### Webpack 68 | 69 | Though we use the same source code for both the server-side and browser rendering, the project will be packaged into two distinct bundles: 70 | 71 | 1. Backend code running on AWS Lambda. The main entry point is `./src/server/render.tsx`. It contains the handler function that is invoked by AWS Lambda. The packaging is controlled by `webpack.server.config.js` and optimized for Node.js 12. 72 | 2. Frontend code hosted in an S3 bucket and loaded by the browser. Main entry point is `./src/browser/index.tsx`. It's packaged using the `webpack.browser.config.js`, optimized for web browsers. The output files will have their content hash added to their names to enable long-term caching in the browser. 73 | 74 | #### Code Splitting 75 | 76 | `webpack.browser.config.js` defines some default code-splitting settings that optimize browser loading times and should make sense for most projects: 77 | 78 | - Shared components (in the `src/components` folder) are loaded in a separate `components.js` chunk. 79 | - All external Node modules (in the `node_modules/` folder) are loaded in the `vendor.js` chunk. External modules usually don't change as often as the rest of your application and this split will improve browser caching for your users. 80 | - The rest of the application is loaded in the `main.js` chunk. 81 | 82 | ## Customization 83 | 84 | ### Serverless Project 85 | 86 | Update the `serverless.yml` with your project name and additional resources you might need. For example, you might want to [create a custom domain name for your app](https://www.serverless.com/plugins/serverless-domain-manager). 87 | 88 | ### Configuration 89 | 90 | The frontend, as well as the server-side code running on AWS Lambda, share a common application configuration. Currently, it is used for injecting the application name from the `public/manifest.json` as well as setting the public hostnames. You can extend the configuration by adding your own variables to `src/server/config.tsx`. They will become available in both your backend and frontend code via the `useConfig` hook: 91 | 92 | ```js 93 | import useConfig from "../components/useConfig"; 94 | 95 | export default function MyComponent() { 96 | const config = useConfig(); 97 | return ( 98 | // ... 99 | ) 100 | } 101 | ``` 102 | 103 | ### Adding Your Own Code 104 | 105 | The boilerplate comes with a preferred folder structure for your project. However, you can change it to your liking. If you decide to do so, make sure to update the respective Webpack and Serverless configurations to point to the new locations. 106 | 107 | Generally, you shouldn't need to touch the contents of the `src/browser/` and `src/server/` folders, with exception of updating the configuration. Common components shared across your React site should go into the `src/components/` folder. It currently contains only the `ConfigContext` provider and the `useConfig` hook implementation. Code splitting has already been configured to package these shared components separately from the rest of your application. You might want to place individual web pages or screens of your application into subfolders directly underneath `src/` or next to `App.tsx`. 108 | 109 | Images can be loaded directly from the `src/` folder as demonstrated in `App.tsx`. Webpack will automatically manage your images, ensure they use a unique file name and are loaded either from S3 or get embedded directly into the generated HTML if they are small enough. The `public/` folder on the other hand is used for static assets that should retain their original file names and folder structure. All content of this folder will be uploaded to S3 exactly 1:1 and served from there. It is the perfect place to put your `favicon.ico`, `robots.txt`, and similar static assets that you need to reference by a fixed unchanging URL. 110 | 111 | ### Adding a backend API 112 | 113 | I recommend creating a _separate_ Serverless service that provides the frontend with an API and protecting it with [Amazon Cognito](https://aws.amazon.com/cognito/), a custom Authorizer, or even just an API Key. Mixing React with pure backend API functions is possible and technically fine, but in my experience, it quickly becomes a hassle and you need to take special care not to leak anything to the browser that's not supposed to be known there. 114 | 115 | ### Redux, React-Router, etc. 116 | 117 | The goal of this boilerplate is to offer a minimal setup that can be used as a basis for pretty much all your React needs. A lot of people love [Redux](https://redux.js.org/), rely on [React Router](https://reactrouter.com/) or need other external modules. I have intentionally left these out of the boilerplate code but it should be trivial to add them, following the standard documentation steps. 118 | 119 | If you are interested in integrating with [React Router](https://reactrouter.com/), check out out the [Added React Router example configuration](https://github.com/arabold/serverless-react-boilerplate/pull/16/files) Pull Request. 120 | 121 | ### Sass, Styled Components, etc. 122 | 123 | Similar to the statement above, I have decided against integrating with a specific framework. The boilerplate uses plain and simple CSS and integrating another system should be easy enough. 124 | 125 | ### Code Formatting & Adding ESLint 126 | 127 | To keep this repository lightweight no ESLint rules are included. There are many different plugins and people tend to prefer different coding styles. The existing code should be easily adaptable to any style you personally prefer. I recommend using [Prettier](https://prettier.io/) to format your code automatically and a default configuration is already part of this repository, defined in `package.json`. In addition, I recommend adding [ESLint](https://eslint.org/) and [Husky](https://github.com/typicode/husky) to your project to ensure your coding guidelines are followed. 128 | 129 | ## Testing 130 | 131 | You can test the setup locally. No direct access to AWS is needed. This allows developers to write and test code even if not everyone has full deployment access. 132 | 133 | For local testing run the following command and open your web browser at http://localhost:3000/. Static content such as images will be served via the [Webpack DevServer](https://webpack.js.org/configuration/dev-server/) running on http://localhost:8080. Note that the app has to be deployed first before you will be able to run locally. 134 | 135 | ```sh 136 | npm start 137 | ``` 138 | 139 | Testing is set up as well, using Jest and will execute all `*.test.ts` and `*.test.tsx` files in the `src/` directory: 140 | 141 | ```sh 142 | npm test 143 | ``` 144 | 145 | The whole application can be deployed with a single command: 146 | 147 | ```sh 148 | npx sls deploy 149 | ``` 150 | 151 | And finally to remove all AWS resources again run: 152 | 153 | ```sh 154 | npx sls remove 155 | ``` 156 | 157 | This will delete all resources but the distribution S3 bucket. As it still contains the bundles you will have to delete the bucket manually for now. 158 | 159 | ## Changelog 160 | 161 | ### 2021-10-10 162 | 163 | - Updated dependencies to the latest versions. This includes specifically the Webpack Dev Server 4.x. 164 | - Restructured the project structure to be more consistent. `App.tsx` and related files have been moved out for the `browser` folder directly into `src` as it is used by both the server and the browser rendering. You should rarely need to touch the contents of the `browser` or the `server` folder. 165 | - Updated the documentation (this file you're currently reading) 166 | 167 | ### 2021-06-04 168 | 169 | - Updated to React 17 170 | - React "Fast Refresh" (previously known as "Hot Reloading") using the [React Refresh Webpack Plugin](https://github.com/pmmmwh/react-refresh-webpack-plugin). 171 | - Built-in support for [code splitting](https://webpack.js.org/guides/code-splitting/) and [tree shaking](https://webpack.js.org/guides/tree-shaking/) to optimize page loading times. 172 | - Full [TypeScript](https://www.typescriptlang.org/) support using Babel 7 and Webpack 5, including custom [module resolution](https://www.typescriptlang.org/docs/handbook/module-resolution.html). 173 | - Handle server-side errors more gracefully. Update `handler.ts` to add your own custom error handling code such as [Youch](https://github.com/poppinss/youch). 174 | - Code cleanup and simplification 175 | -------------------------------------------------------------------------------- /__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | // caller.target will be the same as the target option from webpack 3 | const isNode = api.caller((caller) => caller && caller.target === "node"); 4 | const isOffline = process.env.IS_OFFLINE; 5 | return { 6 | presets: [ 7 | [ 8 | "@babel/preset-env", 9 | { 10 | useBuiltIns: "entry", 11 | corejs: "3.9", 12 | targets: isNode ? { node: "12" } : { browsers: "last 2 versions" }, 13 | }, 14 | ], 15 | "@babel/preset-typescript", 16 | "@babel/preset-react", 17 | ], 18 | plugins: [ 19 | !isNode && isOffline && "react-refresh/babel", 20 | // TODO: Add your own Babel plugins here 21 | ].filter(Boolean), 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /handler.ts: -------------------------------------------------------------------------------- 1 | import "source-map-support/register"; 2 | import { Context, APIGatewayEvent, APIGatewayProxyResultV2 } from "aws-lambda"; 3 | 4 | export const serve = async (event: APIGatewayEvent, _context: Context): Promise => { 5 | try { 6 | // We use asynchronous import here so we can better catch server-side errors during development 7 | const render = (await import("./src/server/render")).default; 8 | return { 9 | statusCode: 200, 10 | headers: { 11 | "Content-Type": "text/html", 12 | }, 13 | body: await render(event), 14 | }; 15 | } catch (error) { 16 | // Custom error handling for server-side errors 17 | // TODO: Prettify the output, include the callstack, e.g. by using `youch` to generate beautiful error pages 18 | console.error(error); 19 | return { 20 | statusCode: 500, 21 | headers: { 22 | "Content-Type": "text/html", 23 | }, 24 | body: `${error.toString()}`, 25 | }; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", // need access to DOM 3 | collectCoverageFrom: ["./src/**/*.{tsx,ts}"], 4 | // TODO: Add custom setup file - setupFiles: ['./setupJest.js'], 5 | // TODO: Add JEST extended library - setupFilesAfterEnv: ["jest-extended"], 6 | moduleNameMapper: { 7 | // Mock static files 8 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 9 | "/__mocks__/fileMock.ts", 10 | "\\.(css|less|sass)$": "identity-obj-proxy", 11 | // Load `tsconfig.json` path mapping 12 | ...require("jest-module-name-mapper").default(), 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-react-boilerplate", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "cross-env IS_OFFLINE=1 concurrently --kill-others \"npm run start:devserver\" \"npm run start:offline\"", 7 | "start:devserver": "webpack serve --config webpack.browser.config.js --mode development", 8 | "start:offline": "sls offline start", 9 | "build:serverless": "sls package", 10 | "build:browser": "webpack --config webpack.browser.config.js --mode production", 11 | "deploy": "sls deploy", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.0-beta", 16 | "core-js": "^3.9.1", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "regenerator-runtime": "^0.13.7", 20 | "source-map-support": "^0.5.19" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.12.10", 24 | "@babel/preset-env": "^7.12.11", 25 | "@babel/preset-react": "^7.12.10", 26 | "@babel/preset-stage-2": "^7.8.3", 27 | "@babel/preset-typescript": "^7.12.7", 28 | "@types/aws-lambda": "^8.10.71", 29 | "@types/babel__core": "^7.1.12", 30 | "@types/babel__preset-env": "^7.9.1", 31 | "@types/babel-core": "^6.25.6", 32 | "@types/concurrently": "^6.2.0", 33 | "@types/copy-webpack-plugin": "^8.0.0", 34 | "@types/core-js": "^2.5.4", 35 | "@types/file-loader": "^5.0.0", 36 | "@types/jest": "^27.0.2", 37 | "@types/mini-css-extract-plugin": "^2.3.0", 38 | "@types/prettier": "^2.1.6", 39 | "@types/react": "^17.0.9", 40 | "@types/react-dom": "^17.0.6", 41 | "@types/serverless": "^1.78.20", 42 | "@types/source-map-support": "^0.5.3", 43 | "@types/webpack": "^5.28.0", 44 | "@types/webpack-dev-server": "^4.3.1", 45 | "aws-sdk": "^2.834.0", 46 | "babel-jest": "^27.0.2", 47 | "babel-loader": "^8.2.2", 48 | "clean-webpack-plugin": "^4.0.0", 49 | "concurrently": "^6.2.0", 50 | "copy-webpack-plugin": "^9.0.0", 51 | "cross-env": "^7.0.3", 52 | "css-loader": "^6.4.0", 53 | "file-loader": "^6.2.0", 54 | "identity-obj-proxy": "^3.0.0", 55 | "jest": "^27.0.4", 56 | "jest-module-name-mapper": "^0.1.5", 57 | "mini-css-extract-plugin": "^2.4.2", 58 | "null-loader": "^4.0.1", 59 | "prettier": "^2.2.1", 60 | "react-refresh": "^0.10.0", 61 | "serverless": "^3.14.0", 62 | "serverless-offline": "~8.5.0", 63 | "serverless-plugin-scripts": "^1.0.2", 64 | "serverless-s3-deploy": "^0.10.0", 65 | "serverless-webpack": "^5.3.5", 66 | "tsconfig-paths-webpack-plugin": "^3.5.1", 67 | "typescript": "^4.1.3", 68 | "url-loader": "^4.1.1", 69 | "webpack": "^5.19.0", 70 | "webpack-cli": "^4.5.0", 71 | "webpack-dev-server": "^4.3.1", 72 | "webpack-stats-plugin": "^1.0.3" 73 | }, 74 | "prettier": { 75 | "printWidth": 120, 76 | "tabWidth": 2, 77 | "useTabs": false, 78 | "semi": true, 79 | "singleQuote": false, 80 | "quoteProps": "as-needed", 81 | "trailingComma": "all", 82 | "bracketSpacing": true, 83 | "bracketSameLine": false, 84 | "arrowParens": "always" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabold/serverless-react-boilerplate/e9c3efe2c5ac4790e6ce5fccace6c9f6b68eb65a/public/favicon.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "display": "standalone", 12 | "theme_color": "#000000", 13 | "background_color": "#ffffff" 14 | } 15 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | --- 2 | service: serverless-react-boilerplate 3 | frameworkVersion: "3" 4 | useDotenv: true 5 | variablesResolutionMode: 20210326 6 | disabledDeprecations: 7 | - CLI_OPTIONS_SCHEMA # some Serverless plugins haven't been updated yet and generate warnings 8 | 9 | provider: 10 | name: aws 11 | runtime: nodejs12.x 12 | region: ${opt:region, 'us-east-1'} 13 | stage: ${opt:stage, 'dev'} 14 | memorySize: 512 15 | timeout: 6 16 | logRetentionInDays: 7 17 | lambdaHashingVersion: 20201221 # for upcoming Serverless v3 18 | 19 | apiGateway: 20 | shouldStartNameWithService: true # for upcoming Serverless v3 21 | 22 | environment: 23 | SERVERLESS_PROJECT: ${self:service} 24 | SERVERLESS_REGION: ${self:provider.region} 25 | SERVERLESS_STAGE: ${self:provider.stage} 26 | 27 | APP_DIST_URL: ${self:custom.distBucketUrl.${self:provider.region}, self:custom.distBucketUrl.default} 28 | APP_PUBLIC_URL: ${self:custom.distBucketUrl.${self:provider.region}, self:custom.distBucketUrl.default} 29 | APIGATEWAY_URL: 30 | Fn::Join: 31 | - "" 32 | - - https:// 33 | - Ref: ApiGatewayRestApi 34 | - .execute-api. 35 | - Ref: AWS::Region 36 | - .amazonaws.com/ 37 | - ${self:provider.stage} 38 | 39 | plugins: 40 | - serverless-webpack 41 | - serverless-plugin-scripts 42 | - serverless-offline 43 | - serverless-s3-deploy 44 | 45 | functions: 46 | serve: 47 | # Any web request regardless of path or method will be handled by a single Lambda function 48 | handler: handler.serve 49 | events: 50 | - http: 51 | path: / 52 | method: any 53 | cors: true 54 | - http: 55 | path: /{any+} 56 | method: any 57 | cors: true 58 | 59 | custom: 60 | distBucketUrl: 61 | us-east-1: 62 | # us-east-1 uses a different URL format than the other regions 63 | Fn::Join: 64 | - "" 65 | - - https://s3.amazonaws.com/ 66 | - Ref: DistBucket 67 | default: 68 | # All other regions 69 | Fn::Join: 70 | - "" 71 | - - https://s3- 72 | - Ref: AWS::Region 73 | - .amazonaws.com/ 74 | - Ref: DistBucket 75 | 76 | scripts: 77 | hooks: 78 | # Build the client-side script before packaging backend code 79 | package:initialize: "npm run build:browser" 80 | deploy:finalize: "npx sls s3deploy --stage ${self:provider.stage}" 81 | 82 | webpack: 83 | webpackConfig: "webpack.server.config.js" 84 | 85 | assets: 86 | # Automatically copy distribution folder to S3 stopped working; do it manually (see `scripts.hooks.deploy:finalize`) 87 | auto: false 88 | targets: 89 | - bucket: 90 | Ref: DistBucket 91 | acl: public-read 92 | files: 93 | - source: dist/ 94 | headers: 95 | CacheControl: max-age=31104000 # 1 year 96 | globs: 97 | - "**/*" 98 | 99 | serverless-offline: 100 | useChildProcesses: true 101 | noPrependStageInUrl: true 102 | httpPort: 3000 103 | lambdaPort: 3002 104 | 105 | resources: 106 | Resources: 107 | # Customize the API Gateway resource 108 | ApiGatewayRestApi: 109 | Type: AWS::ApiGateway::RestApi 110 | Properties: 111 | # Enable gzip compression 112 | MinimumCompressionSize: 1000 113 | 114 | # S3 Bucket for the distribution bundles 115 | DistBucket: 116 | Type: AWS::S3::Bucket 117 | DeletionPolicy: Delete 118 | Properties: 119 | CorsConfiguration: 120 | CorsRules: 121 | - AllowedHeaders: 122 | - "*" 123 | AllowedMethods: 124 | - "GET" 125 | AllowedOrigins: 126 | - Fn::Join: 127 | - "" 128 | - - https:// 129 | - Ref: ApiGatewayRestApi 130 | - .execute-api. 131 | - Ref: AWS::Region 132 | - .amazonaws.com 133 | MaxAge: 3000 134 | 135 | Outputs: 136 | ApiGatewayRestApi: 137 | Description: API Gateway Endpoint 138 | Value: 139 | Ref: ApiGatewayRestApi 140 | DistBucket: 141 | Description: Distribution S3 Bucket 142 | Value: 143 | Ref: DistBucket 144 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | jest.mock("./components/ConfigContext"); 7 | 8 | it("renders without crashing", () => { 9 | const div = document.createElement("div"); 10 | ReactDOM.render(, div); 11 | ReactDOM.unmountComponentAtNode(div); 12 | }); 13 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | import * as React from "react"; 4 | 5 | import useConfig from "./components/useConfig"; 6 | import logo from "./logo.svg"; 7 | 8 | /** 9 | * Our Web Application 10 | */ 11 | export default function App() { 12 | const config = useConfig(); 13 | return ( 14 |
15 |
16 | logo 17 |

Welcome to {config.app.TITLE}

18 |
19 |

20 | To get started, edit src/App.tsx and save to reload. 21 |

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/browser/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/browser/index.tsx: -------------------------------------------------------------------------------- 1 | // Load polyfills (once, on the top of our web app) 2 | import "core-js/stable"; 3 | import "regenerator-runtime/runtime"; 4 | 5 | import "./index.css"; 6 | 7 | /** 8 | * Frontend code running in browser 9 | */ 10 | import * as React from "react"; 11 | import { hydrate } from "react-dom"; 12 | 13 | import ConfigContext from "../components/ConfigContext"; 14 | import { Config } from "../server/config"; 15 | import App from "../App"; 16 | 17 | const config = (window as any).__CONFIG__ as Config; 18 | delete (window as any).__CONFIG__; 19 | 20 | /** Components added here will _only_ be loaded in the web browser, never for server-side rendering */ 21 | const render = () => { 22 | hydrate( 23 | <> 24 | {/* The configuration is the outmost component. This allows us to read the configuration even in the theme */} 25 | 26 | 27 | 28 | , 29 | document.getElementById("root"), 30 | ); 31 | }; 32 | 33 | render(); 34 | -------------------------------------------------------------------------------- /src/components/ConfigContext.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Provide configuration settings 3 | */ 4 | import { createContext } from "react"; 5 | 6 | import { Config } from "../server/config"; 7 | 8 | const ConfigContext = createContext(undefined); 9 | 10 | export default ConfigContext; 11 | -------------------------------------------------------------------------------- /src/components/__mocks__/ConfigContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | import manifest from "../../../public/manifest.json"; 4 | import { Config } from "../../server/config"; 5 | 6 | const ConfigContext = createContext({ 7 | app: { 8 | TITLE: `${manifest.short_name} Mock`, 9 | THEME_COLOR: manifest.theme_color, 10 | URL: "http://localhost:3000", 11 | DIST_URL: "http://localhost:8080", 12 | PUBLIC_URL: "http://localhost:8080", 13 | }, 14 | }); 15 | 16 | export default ConfigContext; 17 | -------------------------------------------------------------------------------- /src/components/useConfig.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { Config } from "../server/config"; 4 | import ConfigContext from "./ConfigContext"; 5 | 6 | /** 7 | * Hook to read application configuration settings 8 | */ 9 | export default function useConfig(): Config { 10 | const config = useContext(ConfigContext); 11 | if (!config) { 12 | throw new Error("Configuration context not initialized!"); 13 | } 14 | return config; 15 | } 16 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module "*.jpg" { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module "*.png" { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module "*.json" { 17 | const content: string; 18 | export default content; 19 | } 20 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/server/config.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration 3 | */ 4 | import manifest from "../../public/manifest.json"; 5 | 6 | /** Whether we're running on a local desktop or on AWS Lambda */ 7 | const isLocal = process.env.IS_LOCAL || process.env.IS_OFFLINE; 8 | 9 | /** 10 | * Configuration Options 11 | * 12 | * IMPORTANT: 13 | * The config is injected into the client (browser) and accessible through the {@link useConfig} 14 | * hook. However, due to this behavior, it is important NOT to expose any sensitive information 15 | * such as passwords or tokens through the config. 16 | */ 17 | const config = { 18 | /** Application Config */ 19 | app: { 20 | /** Name of the app is loaded from the `manifest.json` */ 21 | TITLE: manifest.short_name, 22 | /** Theme is also loaded from the `manifest.json` */ 23 | THEME_COLOR: manifest.theme_color, 24 | /** URL to our public API Gateway endpoint */ 25 | URL: isLocal ? `http://localhost:3000` : String(process.env.APIGATEWAY_URL), 26 | /** Where the bundled distribution files (`main.js`, `main.css`) are hosted */ 27 | DIST_URL: isLocal ? "http://localhost:8080" : String(process.env.APP_DIST_URL), 28 | /** Where the contents of the `public` folder are hosted (might be the same as `config.app.DIST_URL`) */ 29 | PUBLIC_URL: isLocal ? "http://localhost:8080" : String(process.env.APP_PUBLIC_URL), 30 | }, 31 | }; 32 | 33 | export type Config = typeof config; 34 | export default config; 35 | -------------------------------------------------------------------------------- /src/server/html.tsx: -------------------------------------------------------------------------------- 1 | import { Config } from "./config"; 2 | import { Stats } from './types' 3 | 4 | /** 5 | * This HTML file acts as a template that we insert all our generated 6 | * application code into before sending it to the client as regular HTML. 7 | * Note we're returning a template string from this function. 8 | */ 9 | const html = ({ 10 | stats, 11 | content, 12 | config, 13 | css = '' 14 | }: { 15 | stats: Stats 16 | content: string 17 | config: Config 18 | css?: string 19 | }): string => ` 20 | 21 | 22 | 23 | 24 | 25 | ${config.app.TITLE} 26 | 27 | 28 | ${stats.styles.map(filename => ``).join('\n')} 29 | 30 | 33 | 34 | 35 |
${content}
36 | ${stats.scripts.map(filename => ``).join('\n')} 37 | 38 | ` 39 | 40 | export default html 41 | -------------------------------------------------------------------------------- /src/server/render.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Server Side Rendering 3 | */ 4 | import { APIGatewayEvent } from "aws-lambda"; 5 | import * as React from "react"; 6 | import { renderToString } from "react-dom/server"; 7 | 8 | import App from "../App"; 9 | import ConfigContext from "../components/ConfigContext"; 10 | import config from "./config"; 11 | import html from "./html"; 12 | import { Stats } from "./types"; 13 | 14 | /** 15 | * Server-side rendering 16 | */ 17 | export default async function render(_event: APIGatewayEvent): Promise { 18 | // The stats are generated by the Webpack Stats Plugin (`webpack-stats-plugin`) 19 | const stats = (await import("../../dist/stats.json")) as unknown as Stats; 20 | const content = renderToString( 21 | 22 | 23 | , 24 | ); 25 | return html({ stats, content, config }); 26 | } 27 | -------------------------------------------------------------------------------- /src/server/types.tsx: -------------------------------------------------------------------------------- 1 | /** Webpack `stats.json` file structure */ 2 | export type Stats = { 3 | scripts: string[] 4 | styles: string[] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es6", 5 | "allowJs": true, 6 | "allowSyntheticDefaultImports": true, 7 | "jsx": "react", 8 | "lib": ["dom", "esnext"], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "noImplicitAny": true, 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "strictNullChecks": true, 15 | "resolveJsonModule": true, 16 | "baseUrl": "./" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webpack.browser.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const { StatsWriterPlugin } = require("webpack-stats-plugin"); 5 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 6 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 7 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 8 | const { HotModuleReplacementPlugin } = require("webpack"); 9 | 10 | const isOffline = !!process.env.IS_OFFLINE; 11 | 12 | module.exports = { 13 | entry: { 14 | main: path.join(__dirname, "src/browser/index.tsx"), 15 | }, 16 | target: "web", 17 | mode: isOffline ? "development" : "production", 18 | node: { 19 | __dirname: true, 20 | __filename: true, 21 | }, 22 | devServer: { 23 | hot: true, 24 | headers: { 25 | "Access-Control-Allow-Origin": "*", 26 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", 27 | "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization", 28 | }, 29 | watchFiles: { 30 | paths: ["**/*"], 31 | options: { 32 | ignored: ["**/node_modules", "**/dist", "**/.webpack", "**/.serverless"], 33 | }, 34 | }, 35 | devMiddleware: { 36 | writeToDisk: (filePath) => { 37 | // Always write the stats.json to disk, so we can load it in code 38 | return /\bstats\.json$/.test(filePath); 39 | }, 40 | }, 41 | }, 42 | performance: { 43 | // Turn off size warnings for entry points 44 | hints: false, 45 | }, 46 | optimization: { 47 | runtimeChunk: "single", 48 | splitChunks: { 49 | cacheGroups: { 50 | // TODO: Customize code splitting to your needs 51 | vendor: { 52 | name: "vendor", 53 | test: /[\\/]node_modules[\\/]/, 54 | chunks: "all", 55 | }, 56 | components: { 57 | name: "components", 58 | test: /[\\/]src[\\/]components[\\/]/, 59 | chunks: "all", 60 | minSize: 0, 61 | }, 62 | }, 63 | }, 64 | }, 65 | // React recommends `cheap-module-source-map` for development 66 | devtool: isOffline ? "cheap-module-source-map" : "nosources-source-map", 67 | plugins: [ 68 | new CleanWebpackPlugin(), 69 | new CopyWebpackPlugin({ 70 | patterns: [ 71 | { 72 | // Copy content from `./public/` folder to our output directory 73 | context: "./public/", 74 | from: "**/*", 75 | }, 76 | ], 77 | }), 78 | new MiniCssExtractPlugin({ 79 | filename: isOffline ? "[name].css" : "[name].[contenthash:8].css", 80 | }), 81 | new StatsWriterPlugin({ 82 | filename: "stats.json", 83 | transform(data, _opts) { 84 | const assets = data.assetsByChunkName; 85 | const stats = JSON.stringify( 86 | { 87 | scripts: Object.entries(assets).flatMap(([_asset, files]) => { 88 | return files.filter((filename) => filename.endsWith(".js") && !/\.hot-update\./.test(filename)); 89 | }), 90 | styles: Object.entries(assets).flatMap(([_asset, files]) => { 91 | return files.filter((filename) => filename.endsWith(".css") && !/\.hot-update\./.test(filename)); 92 | }), 93 | }, 94 | null, 95 | 2, 96 | ); 97 | return stats; 98 | }, 99 | }), 100 | isOffline && new HotModuleReplacementPlugin(), 101 | isOffline && new ReactRefreshWebpackPlugin(), 102 | ].filter(Boolean), 103 | module: { 104 | rules: [ 105 | { 106 | test: /\.(ts|js)x?$/, 107 | exclude: /node_modules/, // we shouldn't need processing `node_modules` 108 | use: "babel-loader", 109 | }, 110 | { 111 | test: /\.css$/, 112 | use: [MiniCssExtractPlugin.loader, "css-loader"], 113 | }, 114 | { 115 | test: /\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$/, 116 | use: [ 117 | { 118 | loader: "url-loader", 119 | options: { limit: 8192 }, 120 | }, 121 | ], 122 | }, 123 | ], 124 | }, 125 | resolve: { 126 | // TsconfigPathsPlugin applies the path aliases defined in `.tsconfig.json` 127 | plugins: [new TsconfigPathsPlugin()], 128 | extensions: [".browser.tsx", ".browser.ts", ".browser.jsx", ".browser.js", ".tsx", ".ts", ".jsx", ".js"], 129 | }, 130 | output: { 131 | path: path.join(__dirname, "dist"), 132 | filename: isOffline ? "[name].js" : "[name].[contenthash:8].js", 133 | crossOriginLoading: "anonymous", // enable cross-origin loading of chunks 134 | }, 135 | }; 136 | -------------------------------------------------------------------------------- /webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const slsw = require("serverless-webpack"); 3 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: "node", 8 | mode: slsw.lib.webpack.isLocal ? "development" : "production", 9 | node: { 10 | __dirname: true, 11 | __filename: true, 12 | }, 13 | optimization: { 14 | minimize: false, // We don't need to minimize our Lambda code. 15 | moduleIds: "named", 16 | }, 17 | performance: { 18 | // Turn off size warnings for entry points 19 | hints: false, 20 | }, 21 | devtool: "nosources-source-map", 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(ts|js)x?$/, 26 | exclude: /node_modules/, // we shouldn't need processing `node_modules` 27 | use: "babel-loader", 28 | }, 29 | { 30 | test: /\.css$/, 31 | use: "null-loader", // No server-side CSS processing 32 | }, 33 | { 34 | test: /\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$/, 35 | use: "url-loader", 36 | }, 37 | ], 38 | }, 39 | resolve: { 40 | // TsconfigPathsPlugin applies the path aliases defined in `.tsconfig.json` 41 | plugins: [new TsconfigPathsPlugin()], 42 | extensions: [".server.tsx", ".server.ts", ".server.jsx", ".server.js", ".tsx", ".ts", ".jsx", ".js"], 43 | }, 44 | output: { 45 | libraryTarget: "commonjs2", 46 | path: path.join(__dirname, ".webpack"), 47 | filename: "[name].js", 48 | sourceMapFilename: "[file].map", 49 | }, 50 | }; 51 | --------------------------------------------------------------------------------