├── .circleci
└── config.yml
├── .dockerignore
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .production
├── .sequelizerc
└── Dockerfile
├── .sequelizerc
├── .stylelintrc
├── Dockerfile
├── LICENSE
├── README.MD
├── api
├── .babelrc
├── .eslintrc
├── components
│ └── Html.jsx
├── controllers
│ └── exampleController.js
├── db.config.js
├── index.js
├── migrations
│ └── 20190417092622-initial.js
├── models
│ ├── Book.js
│ ├── User.js
│ ├── index.js
│ └── sequelize.js
├── tests
│ ├── .eslintrc
│ └── models.test.js
└── utils
│ └── Router.js
├── app
├── .babelrc
├── .eslintrc
├── App.css
├── App.jsx
├── Routes.jsx
├── actions
│ ├── example.js
│ └── index.js
├── components
│ └── ErrorBoundary.jsx
├── createStore.js
├── i18n
│ └── index.js
├── images
│ └── StarterKitTheTribe.png
├── index.jsx
├── reducers
│ ├── example.js
│ └── index.js
├── routes
│ ├── Home.jsx
│ └── Home.scss
└── tests
│ ├── .eslintrc
│ └── App.test.jsx
├── babel.config.js
├── cucumber.js
├── docker-compose.yml
├── docker
├── docker-entrypoint.sh
├── docker-is-script.js
└── selenium
│ └── Dockerfile
├── features
├── .eslintrc
├── HelloWorld.feature
├── pages
│ ├── Base.js
│ ├── Factory.js
│ └── Home.js
├── step_definitions
│ └── HelloWorld-steps.js
└── support
│ ├── hooks.js
│ └── world.js
├── jest.config.js
├── package.json
├── public
├── favicon.png
└── locales
│ ├── en
│ ├── buttons.json
│ └── translation.json
│ └── fr
│ ├── buttons.json
│ └── translation.json
├── tools
├── .eslintrc
├── build.js
├── bundle.js
├── clean.js
├── copy.js
├── lib
│ ├── WebpackPackagePlugin.js
│ ├── fileTransformer.js
│ └── fs.js
├── run.js
├── start.js
└── webpack.config.js
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | docker: circleci/docker@1.0.0
5 | saucelabs: saucelabs/sauce-connect@1.0.1
6 |
7 | jobs:
8 | yarn-install:
9 | docker:
10 | - image: circleci/node:12.16.0
11 | steps:
12 | - checkout
13 | - restore_cache:
14 | keys:
15 | - yarn-install-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
16 | - yarn-install-{{ checksum "package.json" }}
17 | - yarn-install
18 | paths:
19 | - node_modules
20 | - run:
21 | command: yarn install --frozen-lockfile
22 | - save_cache:
23 | key: yarn-install-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
24 | paths:
25 | - node_modules
26 | - persist_to_workspace:
27 | root: ~/project
28 | paths:
29 | - node_modules
30 |
31 | lint:
32 | docker:
33 | - image: circleci/node:12.16.0
34 | steps:
35 | - checkout
36 | - attach_workspace:
37 | at: ~/project
38 | - run: yarn lint:js
39 | - run: yarn lint:css
40 |
41 | build:
42 | docker:
43 | - image: circleci/node:12.16.0
44 | steps:
45 | - checkout
46 | - attach_workspace:
47 | at: ~/project
48 | - run:
49 | name: Build app
50 | command: yarn build
51 | - persist_to_workspace:
52 | root: ~/project
53 | paths:
54 | - build
55 |
56 | unit-tests:
57 | docker:
58 | - image: circleci/node:12.16.0
59 | steps:
60 | - checkout
61 | - attach_workspace:
62 | at: ~/project
63 | - run:
64 | name: Run tests
65 | command: yarn test
66 |
67 | functional-tests:
68 | docker:
69 | - image: circleci/node:12.16.0
70 | environment:
71 | DATABASE_HOST: localhost
72 | DATABASE_USER: thetribe
73 | DATABASE_NAME: thetribe
74 | DATABASE_PASSWORD: 424242
75 | SAUCELABS_HOST: localhost
76 | - image: circleci/postgres:10.7
77 | environment:
78 | POSTGRES_USER: thetribe
79 | POSTGRES_PASSWORD: 424242
80 | POSTGRES_DB: thetribe
81 | steps:
82 | - checkout
83 | - attach_workspace:
84 | at: ~/project
85 | - run:
86 | name: Wait for database
87 | command: dockerize -wait tcp://localhost:5432 -timeout 1m
88 | - run:
89 | name: Run migrations
90 | command: yarn sequelize db:migrate
91 | - run:
92 | name: Start application
93 | command: yarn start
94 | background: true
95 | - run:
96 | name: Setup host
97 | command: echo '127.0.0.1 app.local' | sudo tee -a /etc/hosts
98 | - run:
99 | name: Wait for app
100 | command: 'dockerize -wait http://app.local:3000 -wait-http-header "Accept: */*" -timeout 1m'
101 | - saucelabs/install
102 | - saucelabs/open_tunnel:
103 | tunnel_identifier: $CIRCLE_PROJECT_REPONAME-$CIRCLE_BUILD_NUM
104 | - run:
105 | name: Run tests
106 | command: |
107 | export SAUCELABS_TUNNEL_IDENTIFIER=$CIRCLE_PROJECT_REPONAME-$CIRCLE_BUILD_NUM
108 |
109 | yarn test:func:sauce:chrome
110 | yarn test:func:sauce:firefox
111 | #yarn test:func:sauce:ie
112 | #yarn test:func:sauce:safari
113 | - saucelabs/close_tunnel
114 |
115 | # Example of Sentry release & sourcemap upload job
116 | # TODO while bootstrapping, setup your projet name, uncomment the job and uncomment
117 | # it's call in workflows
118 | # Note: You may need multiple sentry jobs if you have an app and an api for example
119 | #
120 | # sentry-release:
121 | # docker:
122 | # - image: getsentry/sentry-cli:1.40.0
123 | # entrypoint: ''
124 | # environment:
125 | # SENTRY_PROJECT: DEFINE-YOUR-PROJECT-NAME-HERE
126 | # steps:
127 | # - attach_workspace:
128 | # at: ~/project
129 | # - run: sentry-cli releases new --project ${SENTRY_PROJECT} ${SENTRY_PROJECT}@${CIRCLE_SHA1}
130 | # - run: sentry-cli releases files ${SENTRY_PROJECT}@${CIRCLE_SHA1} upload-sourcemaps build --ignore '*.css.map'
131 | # - run: sentry-cli releases finalize ${SENTRY_PROJECT}@${CIRCLE_SHA1}
132 |
133 | workflows:
134 | version: 2
135 |
136 | build:
137 | jobs:
138 | - docker/hadolint:
139 | dockerfiles: .production/Dockerfile,docker/selenium/Dockerfile,Dockerfile
140 | - yarn-install
141 | - lint:
142 | requires:
143 | - yarn-install
144 | - build:
145 | requires:
146 | - yarn-install
147 | - unit-tests:
148 | requires:
149 | - yarn-install
150 | - functional-tests:
151 | context: saucelabs
152 | requires:
153 | - yarn-install
154 | # TODO while bootstrapping, uncomment this block to activate workflow
155 | # - sentry-release:
156 | # context: sentry
157 | # requires:
158 | # - build
159 | # filters:
160 | # branches:
161 | # only:
162 | # - develop
163 | # - master
164 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /build
3 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # http://editorconfig.org
4 |
5 | root = true
6 |
7 | [*]
8 |
9 | # Change these settings to your own preference
10 | indent_style = space
11 | indent_size = 4
12 |
13 | # We recommend you to keep these unchanged
14 | end_of_line = lf
15 | charset = utf-8
16 | trim_trailing_whitespace = true
17 | insert_final_newline = true
18 |
19 | # editorconfig-tools is unable to ignore longs strings or urls
20 | max_line_length = null
21 |
22 |
23 | [{package.json,yarn.lock}]
24 | indent_size = 2
25 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "@thetribe/eslint-config-react"
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 |
4 | # Compiled output
5 | /build
6 |
7 | # Test coverage
8 | coverage
9 |
10 | # Logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 |
15 | # Docker
16 | /docker-compose.override.yml
17 | /watchOptions.config.js
18 |
19 | # Env
20 | /.env
21 |
--------------------------------------------------------------------------------
/.production/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | config: 'db.config.js',
5 | 'migrations-path': 'migrations',
6 | 'models-path': 'models',
7 | 'seeders-path': 'seeders',
8 | };
9 |
--------------------------------------------------------------------------------
/.production/Dockerfile:
--------------------------------------------------------------------------------
1 | ################ Build the application ############################
2 |
3 | FROM node:12.16.0-slim as build
4 |
5 | WORKDIR /usr/src/app
6 |
7 | ENV PATH="/usr/src/app/node_modules/.bin:${PATH}"
8 |
9 | COPY package.json yarn.lock ./
10 | RUN yarn install
11 |
12 | COPY babel.config.js ./
13 | COPY tools ./tools
14 | COPY api ./api
15 | COPY app ./app
16 |
17 | ENV NODE_ENV=production
18 |
19 | RUN yarn build
20 | RUN babel api/migrations -d build/migrations --copy-files
21 | RUN babel api/db.config.js -o build/db.config.js
22 | COPY .production/.sequelizerc ./build
23 |
24 | RUN cp -f build/package.json .
25 |
26 | RUN yarn install
27 |
28 | ################ Build the finale image ###################
29 |
30 | FROM node:12.16.0-slim
31 |
32 | WORKDIR /usr/src/app
33 |
34 | COPY --from=build /usr/src/app/node_modules ./node_modules
35 | COPY --from=build /usr/src/app/build ./
36 |
37 | ENV NODE_ENV=production
38 |
39 | CMD [ "yarn", "start" ]
40 |
--------------------------------------------------------------------------------
/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | config: path.join('api', 'db.config.js'),
5 | 'migrations-path': path.join('api', 'migrations'),
6 | 'models-path': path.join('api', 'models'),
7 | 'seeders-path': path.join('api', 'seeders'),
8 | };
9 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "rules": {
4 | "indentation": 4
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:12.16.0-slim
2 |
3 | COPY docker/docker-entrypoint.sh docker/docker-is-script.js /usr/local/bin/
4 | ENTRYPOINT ["docker-entrypoint.sh"]
5 |
6 | RUN userdel node
7 |
8 | ARG UID=1000
9 | RUN useradd --uid $UID --create-home app
10 | USER app
11 |
12 | WORKDIR /usr/src/project
13 |
14 | ENV PATH="/usr/src/project/node_modules/.bin:${PATH}"
15 |
16 | CMD ["start"]
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 theTribe
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 | 
2 |
3 | ## Overview
4 |
5 | This repository contains the source code for Node-React-Starter-Kit made by theTribe.io developpers team.
6 | The starter kit is built on top of Node.js, Express, React and Redux, containing modern web development tools such as Webpack and Babel.
7 |
8 | A solid starting point for both professionals and newcomers to the industry.
9 |
10 | |**Front-end tools**|**Back-end tools**|**Functional Testing**|**Dev Environment Configuration**|
11 | |---|---|---|---|
12 | |React|Express|Selenium|Docker|
13 | |Redux|Postgresql|Cucumber.js|
14 | |CSS/SCSS|Sequelize|Saucelabs|
15 |
16 | ## Customization
17 |
18 | The `master` branch of the starter kit does not include advanced integrations.
19 | However we do provide variants that you can use as a reference.
20 |
21 | * [variant/graphql](https://github.com/thetribeio/node-react-starter-kit/tree/variant/graphql) :
22 | Provide an [GraphQL][gql] API running with [apollo][apollo] client.
23 | * [variant/ssr-graphql](https://github.com/thetribeio/node-react-starter-kit/tree/variant/ssr-graphql) :
24 | Provide server side rendering support with a [GraphQL][gql] API running with [apollo][apollo] client.
25 |
26 | [gql]: https://graphql.org
27 | [apollo]: https://www.apollographql.com/docs/react/
28 |
29 | ## Getting Started
30 |
31 | ### Installation
32 |
33 | #### start the app
34 |
35 | To run locally the project you should first install the dependencies
36 |
37 | ```bash
38 | # for local environment
39 | yarn install
40 | yarn start
41 |
42 | # for docker environment
43 | docker-compose up -d
44 | docker-compose stop app
45 | docker-compose run --rm app yarn install
46 | docker-compose run --rm app sequelize db:migrate
47 | docker-compose start app
48 | ```
49 |
50 | You may create a file `docker-compose.override.yml` at the root to override your configuration.
51 | Most likely you might be interested in opening a port on the host, or use your yarn cache in docker containers.
52 |
53 | ```yaml
54 | version: '2.4'
55 | services:
56 | app:
57 | volumes:
58 | - ~/.cache/yarn:/home/app/.cache/yarn:rw
59 | ports:
60 | - 3000:3000
61 | ```
62 |
63 |
64 | ## Configure your user id
65 |
66 | The containers are configured tu run with an user with ID 1000 to remove permissions problems, but if your user ID is
67 | not 1000 you will need to configure the images to use your user ID.
68 |
69 | You can get the your user ID by running `id -u`
70 |
71 | If your user ID is not 1000 you will need to add the following config to your `.env`
72 |
73 | ```
74 | UID=YourUID
75 | ```
76 |
77 | And then run `docker-compose build` to rebuild your containers.
78 |
79 | ### run the linter on your code
80 |
81 | ```bash
82 | # run locally
83 | yarn lint
84 |
85 | # run in docker
86 | docker-compose run --rm app lint
87 | ```
88 |
89 | ### build the app
90 |
91 | ```bash
92 | # to build locally
93 | yarn build
94 |
95 | # to build a production image (docker)
96 | docker build -f .production/Dockerfile -t [image:tag] .
97 | ```
98 |
99 | ### Run the production image locally
100 |
101 | You might try to run the production image locally with `docker-compose`, to do so, simply create a new directory anywhere and use the following configuration.
102 |
103 | ```yaml
104 | version: '2.0'
105 | services:
106 | app:
107 | # use the right tag
108 | image: [image:tag]
109 | environment:
110 | DATABASE_HOST: postgres
111 | DATABASE_NAME: thetribe
112 | DATABASE_USER: thetribe
113 | DATABASE_PASSWORD: 424242
114 | # set the env as you need it
115 | depends_on:
116 | - postgres
117 | ports:
118 | - 3000:3000
119 | postgres:
120 | image: postgres:10.7
121 | environment:
122 | POSTGRES_USER: thetribe
123 | POSTGRES_PASSWORD: 424242
124 | ```
125 |
126 | ### Watch options
127 |
128 | You may provide watch options for the compiler simply by writing a file named `watchOptions.config.js` at the root directory.
129 |
130 | ```js
131 | module.exports = {
132 | // Watching may not work with NFS and machines in VirtualBox
133 | // Uncomment next line if it is your case (use true or interval in milliseconds)
134 | // poll: true,
135 | // Decrease CPU or memory usage in some file systems
136 | // ignored: /node_modules/,
137 | };
138 | ```
139 |
140 | ### Global & module style sheets
141 |
142 | You may import style sheets two ways in your app.
143 | Firstly, if you important a style sheets from the directories `app/components` or `app/routes`,
144 | your style will be imported as a module.
145 | It means you have to import it and manipulate it that way ;
146 |
147 | ```js
148 | // import it as a module
149 | import style from './style.css';
150 |
151 | // and use it that way
152 |
153 | ```
154 |
155 | However if you import a style sheet from elsewhere (node modules or another location in your sources),
156 | it wil be imported as a global. It means you have to import it that way ;
157 |
158 | ```js
159 | import './style.css';
160 | ```
161 |
162 | You may either import CSS style sheets or SASS stylesheet (using the extension `.scss`).
163 |
164 | ### Inject settings to frontend (appData)
165 |
166 | The backend renders the HTML entry point for your application.
167 | By doing so, it allows you to inject settings into your frontend application.
168 |
169 | First you've to push your data into an object on your server side.
170 |
171 | ```js
172 | // api/index.js
173 | const appData = {
174 | /* ... */
175 | myInjectedSettings: 42,
176 | /* ... */
177 | };
178 | ```
179 |
180 | Then you may get those injected settings anywhere in your react scope.
181 | You may either use a `custom hook`.
182 |
183 | ```js
184 | import { useAppData } from '@app/App';
185 |
186 | const MyComponent = () => {
187 | const { myInjectedSettings } = useAppData();
188 |
189 | /* ... */
190 | };
191 | ```
192 |
193 | Or use the `context consumer` directly.
194 |
195 | ```js
196 | import { AppDataContext } from '@app/App';
197 |
198 | class MyComponent extends Component {
199 | render() {
200 | const { myInjectedSettings } = this.context;
201 |
202 | /* ... */
203 | }
204 | }
205 |
206 | MyComponent.contextType = AppDataContext;
207 | ```
208 |
209 | The injected settings (we call here `appData`) are also available in `thunk reducers`
210 |
211 | ```js
212 | const asyncActionCreator = () => (dispatch, getState, { appData }) => {
213 | const { myInjectedSettings } = appData;
214 |
215 | /* ... */
216 | };
217 | ```
218 |
--------------------------------------------------------------------------------
/api/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | [
4 | "module-resolver", {
5 | "alias": {
6 | "@api": "./api",
7 | }
8 | }
9 | ]
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/api/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true
4 | },
5 | "settings": {
6 | "import/internal-regex": "^@api/",
7 | "import/resolver": {
8 | "alias": {
9 | extensions: ['.js', '.jsx'],
10 | map: [
11 | ["@api", "./api"]
12 | ]
13 | }
14 | }
15 | },
16 | "globals": {
17 | "__DEV__": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/api/components/Html.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const Html = ({ appData, manifest: { js: scripts, css: styles } }) => (
5 |
6 |
7 |
8 | theTribe
9 |
10 |
11 | {styles.map((style) => )}
12 |
13 |
14 |
15 | {scripts.map((script) => )}
16 |
17 |
18 | );
19 |
20 | Html.propTypes = {
21 | appData: PropTypes.shape({}).isRequired,
22 | manifest: PropTypes.shape({
23 | css: PropTypes.arrayOf(PropTypes.string).isRequired,
24 | js: PropTypes.arrayOf(PropTypes.string).isRequired,
25 | }).isRequired,
26 | };
27 |
28 | export default Html;
29 |
--------------------------------------------------------------------------------
/api/controllers/exampleController.js:
--------------------------------------------------------------------------------
1 | import { Book } from '@api/models';
2 | import Router from '@api/utils/Router';
3 |
4 | const router = new Router();
5 |
6 | router.get('/', (req, res) => {
7 | res.json({ isWorking: false });
8 | });
9 |
10 | router.get('/books', async (req, res) => {
11 | const books = await Book.findAll();
12 |
13 | return res.json(books.map((book) => book.toJSON()));
14 | });
15 |
16 | router.post('/click', (req, res) => {
17 | res.sendStatus(200);
18 | });
19 |
20 | export default router;
21 |
--------------------------------------------------------------------------------
/api/db.config.js:
--------------------------------------------------------------------------------
1 | require('pg').defaults.parseInt8 = true;
2 |
3 | module.exports = {
4 | dialect: 'postgres',
5 | dialectOptions: {
6 | ssl: '1' === process.env.DATABASE_SSL,
7 | },
8 | username: process.env.DATABASE_USER,
9 | password: process.env.DATABASE_PASSWORD,
10 | database: process.env.DATABASE_NAME,
11 | host: process.env.DATABASE_HOST,
12 | define: {
13 | freezeTableName: true,
14 | underscored: true,
15 | },
16 | pool: {
17 | max: 5,
18 | min: 0,
19 | idle: 60000,
20 | evict: 60000,
21 | acquire: 60000,
22 | },
23 | // we don't want to log options
24 | logging: (query) => console.info(query),
25 | };
26 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import * as Sentry from '@sentry/node';
3 | import history from 'connect-history-api-fallback';
4 | import cors from 'cors';
5 | import express from 'express';
6 | import PrettyError from 'pretty-error';
7 | import { createElement } from 'react';
8 | import { renderToStaticMarkup } from 'react-dom/server';
9 | import Html from '@api/components/Html';
10 | import exampleController from '@api/controllers/exampleController';
11 |
12 | const getManifest = () => {
13 | if (__DEV__) {
14 | // eslint-disable-next-line global-require, import/no-extraneous-dependencies
15 | return require('import-fresh')('./chunk-manifest.json');
16 | }
17 |
18 | // eslint-disable-next-line global-require, import/no-unresolved
19 | return require('./chunk-manifest.json');
20 | };
21 |
22 | // appData to provide through the index.html
23 | // can be used to send environment settings to front
24 | const appData = {
25 | sentryDsn: process.env.SENTRY_PUBLIC_DSN,
26 | sentryEnv: process.env.SENTRY_ENVIRONMENT,
27 | };
28 |
29 | /* configure Sentry */
30 | Sentry.init({
31 | dsn: process.env.SENTRY_DSN,
32 | environment: process.env.SENTRY_ENVIRONMENT,
33 | });
34 |
35 | /* configure the express server */
36 | const server = express();
37 |
38 | // the first middleware must be the sentry request handler
39 | server.use(Sentry.Handlers.requestHandler());
40 |
41 | // set CORS and JSON middleware
42 | server.use(cors());
43 | server.use(express.json());
44 |
45 | // serve public files
46 | const statics = express.static(path.resolve(__dirname, 'public'));
47 | server.use(statics);
48 |
49 | // controllers
50 | server.use('/example', exampleController);
51 |
52 | // then fallback
53 | server.use(history());
54 |
55 | const renderHtml = () => {
56 | // get the manifest (and support async manifest updates on dev mode)
57 | const { client: manifest } = getManifest();
58 |
59 | // prepare the html for index.html
60 | const element = createElement(Html, {
61 | appData,
62 | manifest,
63 | });
64 |
65 | return `${renderToStaticMarkup(element)}`;
66 | };
67 |
68 | let getHtml;
69 |
70 | if (__DEV__) {
71 | // on development we always render html on request
72 | getHtml = renderHtml;
73 | } else {
74 | // on production we render the html once only
75 | const html = renderHtml();
76 |
77 | getHtml = () => html;
78 | }
79 |
80 | // serve it
81 | server.get('/index.html', (req, res) => {
82 | res.setHeader('Cache-Control', 'max-age=0, no-cache');
83 | res.send(getHtml());
84 | });
85 |
86 | // Use the sentry error handler before any other error handler
87 | server.use(Sentry.Handlers.errorHandler());
88 |
89 | // then prepare pretty error to log our errors
90 | const pe = new PrettyError();
91 | pe.skipNodeFiles();
92 | pe.skipPackage('express');
93 |
94 | // then here comes our error handler
95 | // eslint-disable-next-line no-unused-vars
96 | server.use((err, req, res, next) => {
97 | console.error(pe.render(err));
98 | res.status('500')
99 | .send('Internal error');
100 | });
101 |
102 | // on production mode we start ourselves the web server
103 | if (!__DEV__) {
104 | const port = process.env.PORT || 3000;
105 |
106 | server.listen(port, () => {
107 | console.info(`The server is running at http://localhost:${port}/`);
108 | });
109 | }
110 |
111 | // export the web server
112 | export default server;
113 |
--------------------------------------------------------------------------------
/api/migrations/20190417092622-initial.js:
--------------------------------------------------------------------------------
1 | export const up = async (queryInterface, Sequelize) => {
2 | await queryInterface.createTable('user', {
3 | id: {
4 | primaryKey: true,
5 | type: Sequelize.UUID,
6 | defaultValue: Sequelize.UUIDV4,
7 | },
8 | email: { type: Sequelize.STRING, unique: true },
9 | password: Sequelize.STRING,
10 | is_active: { type: Sequelize.BOOLEAN, defaultValue: true },
11 | created_at: { allowNull: false, type: Sequelize.DATE },
12 | updated_at: { allowNull: false, type: Sequelize.DATE },
13 | });
14 |
15 | await queryInterface.createTable('book', {
16 | id: {
17 | primaryKey: true,
18 | type: Sequelize.UUID,
19 | defaultValue: Sequelize.UUIDV4,
20 | },
21 | title: Sequelize.STRING,
22 | writer_id: {
23 | type: Sequelize.UUID,
24 | references: {
25 | model: 'user',
26 | key: 'id',
27 | },
28 | onUpdate: 'CASCADE',
29 | onDelete: 'RESTRICT',
30 | },
31 | created_at: { allowNull: false, type: Sequelize.DATE },
32 | updated_at: { allowNull: false, type: Sequelize.DATE },
33 | });
34 | };
35 |
36 | export const down = async (queryInterface) => {
37 | await queryInterface.dropTable('book');
38 | await queryInterface.dropTable('user');
39 | };
40 |
--------------------------------------------------------------------------------
/api/models/Book.js:
--------------------------------------------------------------------------------
1 | import DataTypes from 'sequelize';
2 | import sequelize from './sequelize';
3 |
4 | const Book = sequelize.define('book', {
5 | id: {
6 | primaryKey: true,
7 | type: DataTypes.UUID,
8 | defaultValue: DataTypes.UUIDV4,
9 | },
10 | title: DataTypes.STRING,
11 | });
12 |
13 | Book.associate = ({ User }) => {
14 | Book.belongsTo(User, {
15 | foreignKey: { name: 'writerId', allowNull: false },
16 | as: 'writer',
17 | });
18 | };
19 |
20 | export default Book;
21 |
--------------------------------------------------------------------------------
/api/models/User.js:
--------------------------------------------------------------------------------
1 | import DataTypes from 'sequelize';
2 | import sequelize from './sequelize';
3 |
4 | const User = sequelize.define('user', {
5 | id: {
6 | primaryKey: true,
7 | type: DataTypes.UUID,
8 | defaultValue: DataTypes.UUIDV4,
9 | },
10 | email: { type: DataTypes.STRING, unique: true },
11 | password: DataTypes.STRING,
12 | isActive: { type: DataTypes.BOOLEAN, defaultValue: true },
13 | });
14 |
15 | User.associate = ({ Book }) => {
16 | User.hasMany(Book, { foreignKey: 'writerId' });
17 | };
18 |
19 | export default User;
20 |
--------------------------------------------------------------------------------
/api/models/index.js:
--------------------------------------------------------------------------------
1 | import Book from './Book';
2 | import User from './User';
3 |
4 | const models = { Book, User };
5 |
6 | Object.values(models).forEach((model) => {
7 | if (model.associate) {
8 | model.associate(models);
9 |
10 | // eslint-disable-next-line no-param-reassign
11 | delete model.associate;
12 | }
13 | });
14 |
15 | export { Book, User };
16 |
--------------------------------------------------------------------------------
/api/models/sequelize.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 | import config from '../db.config';
3 |
4 | export default new Sequelize(config);
5 |
--------------------------------------------------------------------------------
/api/tests/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/api/tests/models.test.js:
--------------------------------------------------------------------------------
1 | import Book from '../models/Book';
2 | import User from '../models/User';
3 |
4 | // list of available models
5 | const models = { Book, User };
6 |
7 | beforeAll(() => {
8 | Object.entries(models).forEach(([modelName, model]) => {
9 | let associate = null;
10 |
11 | // has the model an associate method
12 | if (model.associate) {
13 | // mock the associate method
14 | associate = jest.fn(model.associate);
15 | // eslint-disable-next-line no-param-reassign
16 | model.associate = associate;
17 | }
18 |
19 | models[modelName] = { model, associate };
20 | });
21 | });
22 |
23 | test('All models are provided and associated through the entry point', () => {
24 | // get the models module
25 | // eslint-disable-next-line global-require
26 | const exportedModels = require('../models');
27 |
28 | Object.entries(models).forEach(([modelName, { model, associate }]) => {
29 | // the model must be exported
30 | expect(exportedModels).toHaveProperty(modelName);
31 | expect(exportedModels[modelName]).toBe(model);
32 |
33 | if (associate) {
34 | // the associate method must have been called once
35 | expect(associate.mock.calls.length).toBe(1);
36 | }
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/api/utils/Router.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | const methods = ['delete', 'get', 'patch', 'post', 'put'];
4 |
5 | const decorateMethod = (router, method) => {
6 | // Save the original method before decorating it
7 | const originalMethod = router[method];
8 |
9 | // eslint-disable-next-line func-names, no-param-reassign
10 | router[method] = function (path, ...middlewares) {
11 | // Decorate the middlewares
12 | const decoratedMiddlewares = middlewares.flat(Infinity).map((middleware) => {
13 | // Don't decorate error handlers
14 | if (4 === middleware.length) {
15 | return middleware;
16 | }
17 |
18 | return (req, res, next) => {
19 | const result = middleware(req, res, next);
20 |
21 | if (result instanceof Promise) {
22 | result.catch(next);
23 | }
24 | };
25 | });
26 |
27 | // Call the original method
28 | return originalMethod.apply(this, [path, ...decoratedMiddlewares]);
29 | };
30 | };
31 |
32 | /**
33 | * Decorated express Router that automatically catch errors.
34 | *
35 | * This will be removable once we upgrade to express 5 (which is still in alpha).
36 | */
37 | function Router(...args) {
38 | const router = express.Router(...args);
39 |
40 | for (const method of methods) {
41 | decorateMethod(router, method);
42 | }
43 |
44 | return router;
45 | }
46 |
47 | export default Router;
48 |
--------------------------------------------------------------------------------
/app/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | [
4 | "module-resolver", {
5 | "alias": {
6 | "@app": "./app",
7 | }
8 | }
9 | ]
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/app/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true
4 | },
5 | "settings": {
6 | "import/internal-regex": "^@app/",
7 | "import/resolver": {
8 | "alias": {
9 | extensions: ['.js', '.jsx'],
10 | map: [
11 | ["@app", "./app"]
12 | ]
13 | }
14 | }
15 | },
16 | "globals": {
17 | "__DEV__": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: hotpink;
3 | }
4 |
--------------------------------------------------------------------------------
/app/App.jsx:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 | import PropTypes from 'prop-types';
3 | import React, { PureComponent, createContext, useContext, Suspense } from 'react';
4 | import { I18nextProvider } from 'react-i18next';
5 | import { Provider } from 'react-redux';
6 | import { Router } from 'react-router-dom';
7 | import createStore from '@app/createStore';
8 | import i18n from '@app/i18n';
9 | import Routes from '@app/Routes';
10 | import './App.css';
11 |
12 | export const AppDataContext = createContext({});
13 |
14 | export const useAppData = () => useContext(AppDataContext);
15 |
16 | class App extends PureComponent {
17 | constructor(props) {
18 | super(props);
19 |
20 | this.history = createBrowserHistory();
21 | this.store = createStore({}, { history: this.history, appData: props.appData });
22 | }
23 |
24 | render() {
25 | const { appData } = this.props;
26 |
27 | return (
28 |
29 |
30 | loading...
}>
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | App.propTypes = {
44 | appData: PropTypes.shape({}).isRequired,
45 | };
46 |
47 | export default App;
48 |
--------------------------------------------------------------------------------
/app/Routes.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 | import Home from './routes/Home';
4 |
5 | const Routes = () => (
6 |
7 |
8 |
9 | );
10 |
11 | export default Routes;
12 |
--------------------------------------------------------------------------------
/app/actions/example.js:
--------------------------------------------------------------------------------
1 | export const setExampleValue = (key, value) => ({
2 | type: 'EXAMPLE_SET_VALUE',
3 | key,
4 | value,
5 | });
6 |
--------------------------------------------------------------------------------
/app/actions/index.js:
--------------------------------------------------------------------------------
1 | export * from './example';
2 |
--------------------------------------------------------------------------------
/app/components/ErrorBoundary.jsx:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/browser';
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 |
5 | class ErrorBoundary extends Component {
6 | constructor(props) {
7 | super(props);
8 |
9 | this.state = { error: null, eventId: null };
10 | this.showReportDialog = this.showReportDialog.bind(this);
11 | }
12 |
13 | componentDidCatch(error, errorInfo) {
14 | // update the state
15 | this.setState({ error });
16 |
17 | // log the error with sentry
18 | Sentry.withScope((scope) => {
19 | scope.setExtras(errorInfo);
20 | const eventId = Sentry.captureException(error);
21 | this.setState({ eventId });
22 | });
23 | }
24 |
25 | showReportDialog() {
26 | const { eventId } = this.state;
27 |
28 | Sentry.showReportDialog({ eventId });
29 | }
30 |
31 | render() {
32 | const { error } = this.state;
33 |
34 | if (error) {
35 | // render fallback UI
36 | return (
37 |
38 | );
39 | }
40 |
41 | const { children } = this.props;
42 |
43 | // when there's not an error, render children untouched
44 | return children;
45 | }
46 | }
47 |
48 | ErrorBoundary.propTypes = {
49 | children: PropTypes.node,
50 | };
51 |
52 | export default ErrorBoundary;
53 |
--------------------------------------------------------------------------------
/app/createStore.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, compose } from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import rootReducer from '@app/reducers';
4 |
5 | // eslint-disable-next-line no-underscore-dangle
6 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
7 |
8 | export default (initialState = {}, extraArguments = null) => createStore(
9 | rootReducer,
10 | initialState,
11 | composeEnhancers(applyMiddleware(
12 | thunkMiddleware.withExtraArgument(extraArguments),
13 | )),
14 | );
15 |
--------------------------------------------------------------------------------
/app/i18n/index.js:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import backend from 'i18next-xhr-backend';
3 |
4 | i18n
5 | .use(backend)
6 | .init({
7 | loadPath: '/locales/{{lng}}/{{ns}}.json',
8 | debug: __DEV__,
9 | lng: 'en',
10 | fallbackLng: 'en',
11 | // Differents namespaces may be loaded individually when using `useTranlation` for performance concern
12 | // https://react.i18next.com/guides/multiple-translation-files
13 | namespaces: ['translation', 'buttons'],
14 | defaultNS: 'translation',
15 | interpolation: {
16 | escapeValue: false,
17 | },
18 | });
19 |
20 | export default i18n;
21 |
--------------------------------------------------------------------------------
/app/images/StarterKitTheTribe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thetribeio/node-react-starter-kit/ef39a8cecb0e31d7ef37eb1b480932583f4e58f4/app/images/StarterKitTheTribe.png
--------------------------------------------------------------------------------
/app/index.jsx:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/browser';
2 | import React from 'react';
3 | // we can safely allow the dev dependency for deepForceUpdate
4 | // on production, webpack will nullify it
5 | // eslint-disable-next-line import/no-extraneous-dependencies
6 | import deepForceUpdate from 'react-deep-force-update';
7 | import { render } from 'react-dom';
8 | import App from './App';
9 | import ErrorBoundary from './components/ErrorBoundary';
10 |
11 | // get the container
12 | const container = document.getElementById('app');
13 |
14 | // get app data sent by the back
15 | const appData = JSON.parse(container.dataset.app);
16 |
17 | Sentry.init({
18 | dsn: appData.sentryDsn,
19 | environment: appData.sentryEnv,
20 | });
21 |
22 | let appInstance = null;
23 |
24 | // Re-render the app when window.location changes
25 | const renderApp = () => {
26 | try {
27 | let appElement = ;
28 |
29 | if (!__DEV__) {
30 | appElement = (
31 |
32 | {appElement}
33 |
34 | );
35 | }
36 |
37 | // render it
38 | appInstance = render(appElement, container);
39 | } catch (error) {
40 | if (__DEV__) {
41 | throw error;
42 | }
43 |
44 | console.error(error);
45 | }
46 | };
47 |
48 | renderApp();
49 |
50 | // Enable Hot Module Replacement (HMR)
51 | if (module.hot) {
52 | module.hot.accept('./App', () => {
53 | if (appInstance && appInstance.updater.isMounted(appInstance)) {
54 | // Force-update the whole tree, including components that refuse to update
55 | deepForceUpdate(appInstance);
56 | }
57 |
58 | renderApp();
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/app/reducers/example.js:
--------------------------------------------------------------------------------
1 | const initialState = {};
2 |
3 | const reducer = (state = initialState, { type, ...payload }) => {
4 | switch (type) {
5 | case 'EXAMPLE_SET_VALUE':
6 | return { ...state, [payload.key]: payload.value };
7 | default:
8 | return state;
9 | }
10 | };
11 |
12 | export default reducer;
13 |
--------------------------------------------------------------------------------
/app/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import example from './example';
3 |
4 | const rootReducer = combineReducers({
5 | example,
6 | });
7 |
8 | export default rootReducer;
9 |
--------------------------------------------------------------------------------
/app/routes/Home.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import banner from '../images/StarterKitTheTribe.png';
5 | import styles from './Home.scss';
6 |
7 | const Home = () => {
8 | const [count, setCount] = useState(0);
9 |
10 | const onClick = useCallback(async () => {
11 | const response = await fetch('/example/click', {
12 | method: 'POST',
13 | });
14 |
15 | if (response.ok) {
16 | setCount(count + 1);
17 | }
18 | }, [count, setCount]);
19 |
20 | const { t, i18n } = useTranslation(['translation', 'buttons']);
21 |
22 | const changeLanguage = useCallback(() => {
23 | i18n.changeLanguage('en' === i18n.language ? 'fr' : 'en');
24 | }, [i18n]);
25 |
26 | return (
27 |
28 |

29 |
{t('helloWorld')}
30 |
33 |
36 |
37 | );
38 | };
39 |
40 | export default Home;
41 |
--------------------------------------------------------------------------------
/app/routes/Home.scss:
--------------------------------------------------------------------------------
1 | .title {
2 | color: blue;
3 | }
4 |
--------------------------------------------------------------------------------
/app/tests/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/app/tests/App.test.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { ReactReduxContext } from 'react-redux';
3 | import { withRouter } from 'react-router-dom';
4 | import TestRenderer from 'react-test-renderer';
5 | import App from '../App';
6 |
7 | // mock the Routes component's module
8 | jest.mock('../Routes');
9 |
10 | afterEach(() => {
11 | // reset the mock after each test
12 | // eslint-disable-next-line global-require
13 | require('../Routes').default.mockReset();
14 | });
15 |
16 | test('App provide a redux context', () => {
17 | // get the Routes component
18 | // eslint-disable-next-line global-require
19 | const Routes = require('../Routes').default;
20 |
21 | // mock its implemantion
22 | Routes.mockImplementation(() => {
23 | const context = useContext(ReactReduxContext);
24 |
25 | // the redux context must be defined
26 | expect(context).toBeDefined();
27 | expect(context).not.toBeNull();
28 |
29 | return null;
30 | });
31 |
32 | // render the app
33 | TestRenderer.create();
34 |
35 | // ensure the Routes component has been rendered at least once
36 | expect(Routes.mock.calls.length).toBeGreaterThan(0);
37 | });
38 |
39 | test('App provide a router context', () => {
40 | // get the Routes component
41 | // eslint-disable-next-line global-require
42 | const Routes = require('../Routes').default;
43 |
44 | // create a fake Component
45 | const FakeComponent = jest.fn(() => null);
46 |
47 | // mock its implementation
48 | Routes.mockImplementation(withRouter(FakeComponent));
49 |
50 | // render the app
51 | TestRenderer.create();
52 |
53 | // ensure the Routes component has been rendered at least once
54 | expect(Routes.mock.calls.length).toBeGreaterThan(0);
55 |
56 | // ensure the router context is available
57 | expect(FakeComponent.mock.calls[0][0]).toMatchObject({
58 | match: {},
59 | location: {},
60 | history: {},
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | // Babel configuration
2 | // https://babeljs.io/docs/usage/api/
3 | module.exports = {
4 | presets: [
5 | [
6 | '@babel/preset-env',
7 | {
8 | targets: {
9 | node: 'current',
10 | },
11 | },
12 | ],
13 | '@babel/preset-react',
14 | ],
15 | ignore: ['node_modules', 'build'],
16 | };
17 |
--------------------------------------------------------------------------------
/cucumber.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | default: '--format-options \'{"snippetInterface": "synchronous"}\'',
3 | };
4 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.4'
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | args:
8 | UID: ${UID:-1000}
9 | environment:
10 | NODE_ENV: development
11 | DATABASE_USER: thetribe
12 | DATABASE_PASSWORD: 424242
13 | DATABASE_HOST: postgres
14 | DATABASE_NAME: thetribe
15 | depends_on:
16 | - postgres
17 | volumes:
18 | - ./:/usr/src/project
19 | networks:
20 | default:
21 | aliases:
22 | - app.local
23 | postgres:
24 | image: postgres:10.7
25 | environment:
26 | PGUSER: thetribe
27 | PGPASSWORD: 424242
28 | POSTGRES_USER: thetribe
29 | POSTGRES_PASSWORD: 424242
30 | volumes:
31 | - db:/var/lib/postgresql/data
32 | selenium:
33 | build:
34 | context: docker/selenium
35 | args:
36 | UID: ${UID:-1000}
37 | environment:
38 | DISPLAY: ${DISPLAY}
39 | volumes:
40 | - /tmp/.X11-unix:/tmp/.X11-unix
41 | volumes:
42 | db:
43 |
--------------------------------------------------------------------------------
/docker/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if docker-is-script.js "${1}"; then
5 | set -- yarn "$@"
6 | fi
7 |
8 | exec "$@"
9 |
--------------------------------------------------------------------------------
/docker/docker-is-script.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('fs');
3 | const path = require('path');
4 |
5 | let directory = process.cwd();
6 |
7 | const { root } = path.parse(directory);
8 |
9 | while (!fs.existsSync(`${directory}/package.json`)) {
10 | if (directory === root) {
11 | process.exit(2);
12 | }
13 |
14 | directory = path.dirname(directory);
15 | }
16 |
17 | // eslint-disable-next-line global-require, import/no-dynamic-require
18 | process.exit(process.argv[2] in require(`${directory}/package.json`).scripts ? 0 : 1);
19 |
--------------------------------------------------------------------------------
/docker/selenium/Dockerfile:
--------------------------------------------------------------------------------
1 | # hadolint ignore=DL3006
2 | FROM thetribe/selenium
3 |
4 | # Configure user
5 | ARG UID=1000
6 | RUN useradd --uid $UID --create-home user
7 | USER user
8 |
--------------------------------------------------------------------------------
/features/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | },
5 | "rules": {
6 | "func-names": "off",
7 | "import/no-extraneous-dependencies": ["error", {
8 | devDependencies: true,
9 | optionalDependencies: false
10 | }],
11 | "import/prefer-default-export": "off",
12 | "prefer-arrow-callback": "off"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/features/HelloWorld.feature:
--------------------------------------------------------------------------------
1 | Feature: Hello world
2 | Display hello world on home page
3 |
4 | Scenario: Display hello world on home page
5 | Given I am on Home page
6 | When I press the "You clicked 0 times" button
7 | Then "You clicked 1 time" is displayed in Home page
8 |
9 | Scenario: Translate content
10 | Given I am on Home page
11 | When I press the "Translate in fr" button
12 | Then "Vous avez cliqué 0 fois" is displayed in Home page
13 |
--------------------------------------------------------------------------------
/features/pages/Base.js:
--------------------------------------------------------------------------------
1 | import { until } from 'selenium-webdriver';
2 |
3 | export default class Base {
4 | constructor(World) {
5 | this.browser = World.browser;
6 | this.driver = World.driver;
7 | this.host = World.host;
8 | }
9 |
10 | loadAndWaitUntilVisible() {
11 | this.load();
12 |
13 | return this.waitUntilVisible();
14 | }
15 |
16 | waitUntilElementIsVisible(locator) {
17 | const element = this.driver.wait(until.elementLocated(locator));
18 |
19 | return this.driver.wait(until.elementIsVisible(element));
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/features/pages/Factory.js:
--------------------------------------------------------------------------------
1 | import Home from './Home';
2 |
3 | export default class Factory {
4 | constructor(World) {
5 | this.World = World;
6 | this.pages = {
7 | Home,
8 | };
9 | }
10 |
11 | create(name) {
12 | const PageClass = this.pages[name];
13 | if (null === PageClass) {
14 | throw new Error(name);
15 | }
16 |
17 | return new PageClass(this.World);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/features/pages/Home.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import { By, until } from 'selenium-webdriver';
3 | import Base from './Base';
4 |
5 | class Home extends Base {
6 | load() {
7 | this.driver.get(this.host);
8 | }
9 |
10 | waitUntilVisible() {
11 | return this.driver.wait(until.titleIs('theTribe'));
12 | }
13 |
14 | async assertTextInClickMe(text) {
15 | const btnText = await this.driver.findElement(By.id('click-me')).getText();
16 | expect(btnText).toBe(text);
17 | }
18 |
19 | clickButtonById(id) {
20 | return this.driver.findElement(By.id(id)).click();
21 | }
22 | }
23 |
24 | export default Home;
25 |
--------------------------------------------------------------------------------
/features/step_definitions/HelloWorld-steps.js:
--------------------------------------------------------------------------------
1 | import { Given, Then, When } from 'cucumber';
2 |
3 | const contentToId = {
4 | 'You clicked 0 times': 'click-me',
5 | 'Translate in fr': 'translate',
6 | };
7 |
8 | Given('I am on Home page', async function () {
9 | this.page = this.pageFactory.create('Home');
10 | await this.page.loadAndWaitUntilVisible();
11 | });
12 |
13 | When('I press the {string} button', async function (content) {
14 | const id = contentToId[content];
15 |
16 | await this.page.clickButtonById(id);
17 | });
18 |
19 | Then(/"(.*)" is displayed in Home page/, async function (text) {
20 | await this.page.assertTextInClickMe(text);
21 | });
22 |
--------------------------------------------------------------------------------
/features/support/hooks.js:
--------------------------------------------------------------------------------
1 | import { After, Before } from 'cucumber';
2 | import SauceLabs from 'saucelabs';
3 |
4 | import { Builder } from 'selenium-webdriver';
5 | import chrome from 'selenium-webdriver/chrome';
6 | import firefox from 'selenium-webdriver/firefox';
7 | import safari from 'selenium-webdriver/safari';
8 |
9 | const { SAUCELABS_HOST, SAUCELABS_KEY, SAUCELABS_TUNNEL_IDENTIFIER, SAUCELABS_USER } = process.env;
10 |
11 | const capabilities = {
12 | ie: {
13 | browserName: 'internet explorer',
14 | iedriverVersion: '3.14.0',
15 | seleniumVersion: '3.14.0',
16 | version: '11.285',
17 | },
18 | chrome: {
19 | browserName: 'chrome',
20 | screenResolution: '1920x1200',
21 | },
22 | firefox: {
23 | browserName: 'firefox',
24 | },
25 | safari: {
26 | browserName: 'safari',
27 | screenResolution: '1920x1440',
28 | },
29 | };
30 |
31 | const getBrowser = (browser) => new Builder()
32 | .withCapabilities({
33 | username: SAUCELABS_USER,
34 | accessKey: SAUCELABS_KEY,
35 | host: SAUCELABS_HOST,
36 | port: 4445,
37 | implicit: 5000,
38 | tunnelIdentifier: SAUCELABS_TUNNEL_IDENTIFIER,
39 | ...capabilities[browser],
40 | })
41 | .usingServer(`http://${SAUCELABS_USER}:${SAUCELABS_KEY}@${SAUCELABS_HOST}:4445/wd/hub`)
42 | .build();
43 |
44 | const getLocalBrowser = (browser, display) => {
45 | let options = null;
46 | let optionsFunc = null;
47 |
48 | switch (browser) {
49 | case 'chrome':
50 | options = new chrome.Options();
51 | options.addArguments('--no-sandbox');
52 | optionsFunc = 'setChromeOptions';
53 | break;
54 | case 'safari':
55 | options = new safari.Options();
56 | optionsFunc = 'setSafariOptions';
57 | break;
58 | default:
59 | options = new firefox.Options();
60 | optionsFunc = 'setFirefoxOptions';
61 | break;
62 | }
63 |
64 | if (!display) {
65 | options.headless();
66 | }
67 |
68 | const builder = new Builder();
69 |
70 | return builder[optionsFunc](options)
71 | .usingServer('http://selenium:4444/wd/hub')
72 | .forBrowser(browser)
73 | .build();
74 | };
75 |
76 | Before(async function () {
77 | if (this.local) {
78 | this.driver = getLocalBrowser(this.browser, this.display);
79 | } else {
80 | this.driver = getBrowser(this.browser);
81 | }
82 | });
83 |
84 | After(async function ({ pickle: { name }, result: { status } }) {
85 | if (!this.local) {
86 | const sauceApi = new SauceLabs({ user: SAUCELABS_USER, key: SAUCELABS_KEY });
87 | // eslint-disable-next-line no-underscore-dangle
88 | const jobId = (await this.driver.getSession()).id_;
89 | const passed = 'passed' === status;
90 | await sauceApi.updateJob(SAUCELABS_USER, jobId, {
91 | name: `${this.browser} - ${name}`,
92 | passed,
93 | });
94 | if (!passed) {
95 | console.info(`Failed test video: https://app.saucelabs.com/tests/${jobId}`);
96 | }
97 | }
98 | await this.driver.quit();
99 | });
100 |
--------------------------------------------------------------------------------
/features/support/world.js:
--------------------------------------------------------------------------------
1 | import { setDefaultTimeout, setWorldConstructor } from 'cucumber';
2 | import Factory from '../pages/Factory';
3 |
4 | setDefaultTimeout(60 * 1000);
5 |
6 | class World {
7 | constructor(args) {
8 | this.browser = args.parameters.browser;
9 | this.local = args.parameters.local;
10 | this.display = args.parameters.display;
11 | this.pageFactory = new Factory(this);
12 | this.host = 'http://app.local:3000';
13 | }
14 | }
15 |
16 | setWorldConstructor(World);
17 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // Jest configuration
2 | // https://facebook.github.io/jest/docs/en/configuration.html
3 | module.exports = {
4 | // Modules can be explicitly auto-mocked using jest.mock(moduleName).
5 | // https://facebook.github.io/jest/docs/en/configuration.html#automock-boolean
6 | automock: false,
7 |
8 | // Respect Browserify's "browser" field in package.json when resolving modules.
9 | // https://facebook.github.io/jest/docs/en/configuration.html#browser-boolean
10 | browser: false,
11 |
12 | // This config option can be used here to have Jest stop running tests after the first failure.
13 | // https://facebook.github.io/jest/docs/en/configuration.html#bail-boolean
14 | bail: false,
15 |
16 | // https://facebook.github.io/jest/docs/en/configuration.html#collectcoveragefrom-array
17 | collectCoverageFrom: [
18 | 'api/**/*.{js,jsx}',
19 | 'app/**/*.{js,jsx}',
20 | '!**/node_modules/**',
21 | '!**/vendor/**',
22 | ],
23 |
24 | // https://facebook.github.io/jest/docs/en/configuration.html#coveragedirectory-string
25 | coverageDirectory: '/coverage',
26 |
27 | globals: {
28 | __DEV__: true,
29 | },
30 |
31 | // The default extensions Jest will look for.
32 | // https://facebook.github.io/jest/docs/en/configuration.html#modulefileextensions-array-string
33 | moduleFileExtensions: ['js', 'json', 'jsx', 'node'],
34 |
35 | // A map from regular expressions to module names that allow to stub out resources,
36 | // like images or styles with a single module.
37 | moduleNameMapper: {
38 | '\\.(css|less|styl|scss|sass|sss)$': 'identity-obj-proxy',
39 | },
40 |
41 | transform: {
42 | '\\.(js|jsx|mjs)$': '/node_modules/babel-jest',
43 | '^(?!.*\\.(js|jsx|json|css|less|styl|scss|sass|sss)$)':
44 | '/tools/lib/fileTransformer.js',
45 | },
46 |
47 | verbose: true,
48 | };
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "UNLICENSED",
3 | "private": true,
4 | "engines": {
5 | "node": ">=12.16.0"
6 | },
7 | "browserslist": [
8 | ">1%",
9 | "last 4 versions",
10 | "Firefox ESR",
11 | "not ie < 9"
12 | ],
13 | "dependencies": {
14 | "@babel/polyfill": "^7.10.1",
15 | "@sentry/browser": "^5.16.1",
16 | "@sentry/node": "^5.16.1",
17 | "connect-history-api-fallback": "^1.6.0",
18 | "cors": "^2.8.5",
19 | "express": "^4.17.1",
20 | "history": "^4.10.1",
21 | "i18next": "^19.4.5",
22 | "i18next-xhr-backend": "^3.2.2",
23 | "node-sass": "^4.14.1",
24 | "pg": "^8.2.1",
25 | "pretty-error": "^2.1.1",
26 | "prop-types": "^15.7.2",
27 | "react": "^16.13.1",
28 | "react-dom": "^16.13.1",
29 | "react-i18next": "^11.5.0",
30 | "react-redux": "^7.2.0",
31 | "react-router-dom": "^5.2.0",
32 | "redux": "^4.0.5",
33 | "redux-thunk": "^2.3.0",
34 | "sequelize": "^5.21.11",
35 | "sequelize-cli": "^5.5.1",
36 | "source-map-support": "^0.5.19"
37 | },
38 | "devDependencies": {
39 | "@babel/cli": "^7.10.1",
40 | "@babel/core": "^7.10.2",
41 | "@babel/node": "^7.10.1",
42 | "@babel/plugin-transform-runtime": "^7.10.1",
43 | "@babel/preset-env": "^7.10.2",
44 | "@babel/preset-react": "^7.10.1",
45 | "@thetribe/eslint-config-react": "^0.4.1",
46 | "babel-jest": "^26.0.1",
47 | "babel-loader": "8.1.0",
48 | "babel-plugin-module-resolver": "^4.0.0",
49 | "css-loader": "^3.5.3",
50 | "cucumber": "^6.0.5",
51 | "eslint": "^6.8.0",
52 | "eslint-import-resolver-alias": "^1.1.2",
53 | "eslint-plugin-import": "^2.20.2",
54 | "eslint-plugin-jest": "^23.13.2",
55 | "eslint-plugin-jsx-a11y": "^6.2.3",
56 | "eslint-plugin-react": "^7.20.0",
57 | "eslint-plugin-react-hooks": "^2.5.1",
58 | "expect": "^26.0.1",
59 | "file-loader": "^6.0.0",
60 | "glob": "^7.1.6",
61 | "identity-obj-proxy": "^3.0.0",
62 | "image-webpack-loader": "^6.0.0",
63 | "import-fresh": "^3.2.1",
64 | "jest": "^26.0.1",
65 | "mini-css-extract-plugin": "^0.9.0",
66 | "null-loader": "^4.0.0",
67 | "react-deep-force-update": "^2.1.3",
68 | "react-dev-utils": "^10.2.1",
69 | "react-error-overlay": "^6.0.7",
70 | "react-test-renderer": "^16.13.1",
71 | "rimraf": "^3.0.2",
72 | "sass-loader": "^8.0.2",
73 | "saucelabs": "^4.4.0",
74 | "selenium-webdriver": "^4.0.0-alpha.7",
75 | "style-loader": "^1.2.1",
76 | "stylelint": "^13.5.0",
77 | "stylelint-config-standard": "^20.0.0",
78 | "terser-webpack-plugin": "^3.0.3",
79 | "webpack": "^4.43.0",
80 | "webpack-assets-manifest": "^3.1.1",
81 | "webpack-bundle-analyzer": "^3.8.0",
82 | "webpack-dev-server": "^3.11.0",
83 | "webpack-node-externals": "^1.7.2"
84 | },
85 | "scripts": {
86 | "lint:js": "eslint --ext js,jsx --ignore-path .gitignore --ignore-pattern \"!**/.*\" .",
87 | "lint:js:fix": "yarn run lint:js --fix",
88 | "lint:css": "stylelint \"app/**/*.{css,scss}\"",
89 | "lint:css:fix": "yarn run lint:css --fix",
90 | "lint": "yarn run lint:js && yarn run lint:css",
91 | "test": "jest",
92 | "test:watch": "yarn run test --watch --notify",
93 | "test:cover": "yarn run test --coverage",
94 | "test:func": "cucumber-js --require-module @babel/register",
95 | "test:func:local:chrome": "cucumber-js --require-module @babel/register --parallel 4 --world-parameters='{\"local\": 1, \"browser\": \"chrome\"}'",
96 | "test:func:local:firefox": "cucumber-js --require-module @babel/register --parallel 4 --world-parameters='{\"local\": 1, \"browser\": \"firefox\"}'",
97 | "test:func:local:safari": "cucumber-js --require-module @babel/register --parallel 4 --world-parameters='{\"local\": 1, \"browser\": \"safari\"}'",
98 | "test:func:visual:chrome": "cucumber-js --require-module @babel/register --world-parameters='{\"local\": 1, \"display\": 1, \"browser\": \"chrome\"}'",
99 | "test:func:visual:firefox": "cucumber-js --require-module @babel/register --world-parameters='{\"local\": 1, \"display\": 1, \"browser\": \"firefox\"}'",
100 | "test:func:visual:safari": "cucumber-js --require-module @babel/register --world-parameters='{\"local\": 1, \"display\": 1, \"browser\": \"safari\"}'",
101 | "test:func:sauce:chrome": "cucumber-js --require-module @babel/register --fail-fast --world-parameters='{\"browser\": \"chrome\"}'",
102 | "test:func:sauce:firefox": "cucumber-js --require-module @babel/register --fail-fast --world-parameters='{\"browser\": \"firefox\"}'",
103 | "test:func:sauce:ie": "cucumber-js --require-module @babel/register --fail-fast --world-parameters='{\"browser\": \"ie\"}'",
104 | "test:func:sauce:safari": "cucumber-js --require-module @babel/register --fail-fast --world-parameters='{ \"browser\": \"safari\"}'",
105 | "clean": "babel-node tools/run clean",
106 | "copy": "babel-node tools/run copy",
107 | "bundle": "babel-node tools/run bundle",
108 | "build": "babel-node tools/run build",
109 | "build:stats": "yarn run build --release --analyze",
110 | "start": "babel-node tools/run start",
111 | "sequelize": "babel-node ./node_modules/sequelize-cli/lib/sequelize"
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thetribeio/node-react-starter-kit/ef39a8cecb0e31d7ef37eb1b480932583f4e58f4/public/favicon.png
--------------------------------------------------------------------------------
/public/locales/en/buttons.json:
--------------------------------------------------------------------------------
1 | {
2 | "youClicked": "You clicked {{count}} time",
3 | "youClicked_plural": "You clicked {{count}} times",
4 | "translate": "Translate in fr"
5 | }
6 |
--------------------------------------------------------------------------------
/public/locales/en/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "helloWorld": "hello world"
3 | }
4 |
--------------------------------------------------------------------------------
/public/locales/fr/buttons.json:
--------------------------------------------------------------------------------
1 | {
2 | "youClicked": "Vous avez cliqué {{count}} fois",
3 | "translate": "Traduire en en"
4 | }
5 |
--------------------------------------------------------------------------------
/public/locales/fr/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "helloWorld": "bonjour monde"
3 | }
4 |
--------------------------------------------------------------------------------
/tools/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "import/no-extraneous-dependencies": "off"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/tools/build.js:
--------------------------------------------------------------------------------
1 | import bundle from './bundle';
2 | import clean from './clean';
3 | import copy from './copy';
4 | import run from './run';
5 |
6 | /**
7 | * Compiles the project from source files into a distributable
8 | * format and copies it to the output (build) folder.
9 | */
10 | async function build() {
11 | await run(clean);
12 | await run(copy);
13 | await run(bundle);
14 | }
15 |
16 | export default build;
17 |
--------------------------------------------------------------------------------
/tools/bundle.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import webpackConfig from './webpack.config';
3 |
4 | /**
5 | * Creates application bundles from the source files.
6 | */
7 | function bundle() {
8 | return new Promise((resolve, reject) => webpack(webpackConfig)
9 | .run((err, stats) => {
10 | if (err) {
11 | return reject(err);
12 | }
13 |
14 | // print out stats
15 | console.info(stats.toString(webpackConfig[0].stats));
16 |
17 | if (stats.hasErrors()) {
18 | return reject(new Error('Webpack compilation errors'));
19 | }
20 |
21 | return resolve();
22 | }));
23 | }
24 |
25 | export default bundle;
26 |
--------------------------------------------------------------------------------
/tools/clean.js:
--------------------------------------------------------------------------------
1 | import { cleanDir } from './lib/fs';
2 |
3 | /**
4 | * Cleans up the output (build) directory.
5 | */
6 | async function clean() {
7 | await cleanDir('build/*', {
8 | nosort: true,
9 | dot: true,
10 | // keep git files
11 | ignore: ['build/.git'],
12 | });
13 | }
14 |
15 | export default clean;
16 |
--------------------------------------------------------------------------------
/tools/copy.js:
--------------------------------------------------------------------------------
1 | import { makeDir, copyDir } from './lib/fs';
2 |
3 | /**
4 | * Copies static files such as robots.txt, favicon.ico to the
5 | * output (build) folder.
6 | */
7 | async function copy() {
8 | await makeDir('build');
9 | await copyDir('public', 'build/public');
10 | }
11 |
12 | export default copy;
13 |
--------------------------------------------------------------------------------
/tools/lib/WebpackPackagePlugin.js:
--------------------------------------------------------------------------------
1 | import { builtinModules } from 'module';
2 | import path from 'path';
3 | import pkg from '../../package.json';
4 |
5 | const pluginName = 'WebpackPackagePlugin';
6 |
7 | const getParentIdentifier = (identifier) => {
8 | if ('@' === identifier[0]) {
9 | return identifier.split('/').slice(0, 2).join('/');
10 | }
11 |
12 | return identifier.split('/')[0];
13 | };
14 |
15 | const defaultOptions = {
16 | additionalModules: [],
17 | };
18 |
19 | export default class WebpackPackagePlugin {
20 | constructor(options) {
21 | this.options = { ...defaultOptions, ...options };
22 | }
23 |
24 | apply(compiler) {
25 | const outputFolder = compiler.options.output.path;
26 | const outputFile = path.resolve(outputFolder, 'package.json');
27 | const outputName = path.relative(outputFolder, outputFile);
28 |
29 | compiler.hooks.emit.tap(pluginName, (compilation) => {
30 | const dependencies = {};
31 |
32 | const addDependency = (module) => {
33 | // avoid core package
34 | if (!builtinModules.includes(module)) {
35 | // look for a match
36 | const target = pkg.dependencies[module];
37 |
38 | if (!target) {
39 | // we fail if the dependencies is not added in the package.json
40 | throw new Error(`the module ${module} is not listed in dependencies`);
41 | }
42 |
43 | // add the dependency
44 | dependencies[module] = target;
45 | }
46 | };
47 |
48 | compilation.modules.forEach(({ request, external }) => {
49 | // we only look for external modules
50 | if (external && !request.startsWith('./')) {
51 | // get the main module identifier and try to add i
52 | addDependency(getParentIdentifier(request));
53 | }
54 | });
55 |
56 | // add additional dependencies
57 | this.options.additionalModules.forEach(addDependency);
58 |
59 | // write the new package.json
60 | const output = JSON.stringify({
61 | ...pkg,
62 | dependencies,
63 | devDependencies: undefined,
64 | scripts: {
65 | start: 'node server.js',
66 | },
67 | });
68 |
69 | // add it through webpack assets
70 | // eslint-disable-next-line no-param-reassign
71 | compilation.assets[outputName] = {
72 | source: () => output,
73 | size: () => output.length,
74 | };
75 | });
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tools/lib/fileTransformer.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | // This is a custom Jest transformer turning file imports into filenames.
4 | // http://facebook.github.io/jest/docs/tutorial-webpack.html
5 | module.exports = {
6 | process(src, filename) {
7 | return `module.exports = ${JSON.stringify(path.basename(filename))};`;
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/tools/lib/fs.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import glob from 'glob';
4 | import rimraf from 'rimraf';
5 |
6 | export const copyFile = (source, target) => new Promise((resolve, reject) => {
7 | let cbCalled = false;
8 |
9 | function done(err) {
10 | if (!cbCalled) {
11 | cbCalled = true;
12 | if (err) {
13 | reject(err);
14 | } else {
15 | resolve();
16 | }
17 | }
18 | }
19 |
20 | const rd = fs.createReadStream(source);
21 | rd.on('error', (err) => done(err));
22 |
23 | const wr = fs.createWriteStream(target);
24 | wr.on('error', (err) => done(err));
25 | wr.on('close', (err) => done(err));
26 |
27 | rd.pipe(wr);
28 | });
29 |
30 | export const readDir = (pattern, options) => new Promise((resolve, reject) => glob(
31 | pattern,
32 | options,
33 | (err, result) => (err ? reject(err) : resolve(result)),
34 | ));
35 |
36 | export const makeDir = (name) => fs.promises.mkdir(name, { recursive: true });
37 |
38 | export const copyDir = async (source, target) => {
39 | const dirs = await readDir('**/*.*', {
40 | cwd: source,
41 | nosort: true,
42 | dot: true,
43 | });
44 |
45 | await Promise.all(
46 | dirs.map(async (dir) => {
47 | const from = path.resolve(source, dir);
48 | const to = path.resolve(target, dir);
49 | await makeDir(path.dirname(to));
50 | await copyFile(from, to);
51 | }),
52 | );
53 | };
54 |
55 | export const cleanDir = (pattern, options) => new Promise((resolve, reject) => rimraf(
56 | pattern,
57 | { glob: options },
58 | (err, result) => (err ? reject(err) : resolve(result)),
59 | ));
60 |
--------------------------------------------------------------------------------
/tools/run.js:
--------------------------------------------------------------------------------
1 | function run(fn, options) {
2 | const task = 'undefined' === typeof fn.default ? fn : fn.default;
3 |
4 | return task(options);
5 | }
6 |
7 | if (require.main === module && process.argv.length > 2) {
8 | // ensure we do not run on cache
9 | // eslint-disable-next-line no-underscore-dangle
10 | delete require.cache[__filename];
11 |
12 | // get the up to date module
13 | // eslint-disable-next-line global-require, import/no-dynamic-require
14 | const module = require(`./${process.argv[2]}.js`).default;
15 |
16 | run(module).catch((err) => {
17 | console.error(err.stack);
18 | process.exit(1);
19 | });
20 | }
21 |
22 | export default run;
23 |
--------------------------------------------------------------------------------
/tools/start.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import chalk from 'react-dev-utils/chalk';
4 | import clearConsole from 'react-dev-utils/clearConsole';
5 | import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
6 | import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
7 | import openBrowser from 'react-dev-utils/openBrowser';
8 | import { prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
9 | import webpack from 'webpack';
10 | import WebpackDevServer from 'webpack-dev-server';
11 | import clean from './clean';
12 | import run from './run';
13 | import webpackConfig from './webpack.config';
14 |
15 | const isInteractive = process.stdout.isTTY;
16 | const host = process.env.HOST || '0.0.0.0';
17 | const port = parseInt(process.env.PORT, 10) || 3000;
18 | const urls = prepareUrls('http', host, port);
19 |
20 | const watchOptionsConfigFile = path.resolve(__dirname, '../watchOptions.config.js');
21 |
22 | // https://webpack.js.org/configuration/watch/#watchoptions
23 | // eslint-disable-next-line import/no-dynamic-require
24 | const watchOptions = fs.existsSync(watchOptionsConfigFile) ? require(watchOptionsConfigFile) : {};
25 |
26 | const createCompilationPromise = (name, compiler, config) => new Promise((resolve, reject) => {
27 | // listen to webpack hooks (done)
28 | compiler.hooks.done.tap(name, (stats) => {
29 | // print out stats
30 | console.info(stats.toString(config.stats));
31 |
32 | if (stats.hasErrors()) {
33 | // reject on errors
34 | reject(new Error('Compilation failed!'));
35 | } else {
36 | // resolve on successful builds
37 | resolve(stats);
38 | }
39 | });
40 | });
41 |
42 | /**
43 | * Launches a development web server with "live reload" functionality -
44 | * synchronizing URLs, interactions and code changes across multiple devices.
45 | */
46 | async function start() {
47 | const [clientConfig, serverConfig] = webpackConfig;
48 |
49 | // clean the build directory
50 | await run(clean);
51 |
52 | // instance the main webpack compiler
53 | const multiCompiler = webpack(webpackConfig);
54 |
55 | // and dissociate from it our client & server compiler
56 | const clientCompiler = multiCompiler.compilers.find((compiler) => 'client' === compiler.name);
57 | const serverCompiler = multiCompiler.compilers.find((compiler) => 'server' === compiler.name);
58 |
59 | // create promises on their compilations
60 | const clientPromise = createCompilationPromise('client', clientCompiler, clientConfig);
61 | const serverPromise = createCompilationPromise('server', serverCompiler, serverConfig);
62 |
63 | let api; // app instance will remain here
64 | let apiPromise; // promise resolving the server
65 | let apiPromiseResolve; // resolve callback of the same promise
66 | let apiPromiseIsResolved = true; // has the promise been resolved already
67 |
68 | const handleByServer = async (req, res, next) => {
69 | try {
70 | await apiPromise;
71 | api.handle(req, res);
72 | } catch (error) {
73 | next(error);
74 | }
75 | };
76 |
77 | // webpack dev server (with HMR)
78 | const devServer = new WebpackDevServer(clientCompiler, {
79 | contentBase: path.resolve(__dirname, '../public'),
80 | disableHostCheck: true,
81 | compress: true,
82 | clientLogLevel: 'none',
83 | hot: true,
84 | publicPath: '/',
85 | quiet: true,
86 | watchOptions,
87 | host,
88 | overlay: false,
89 | historyApiFallback: false,
90 | public: urls.lanUrlForConfig,
91 | after(app) {
92 | // redirect the request to the resolved server
93 | app.use(handleByServer);
94 | },
95 | before(app, server) {
96 | // apply middlewares for dev purposes
97 | app.use(evalSourceMapMiddleware(server));
98 | app.use(errorOverlayMiddleware());
99 | // we have to manually handle the root route
100 | app.get('/', handleByServer);
101 | },
102 | writeToDisk: true,
103 | });
104 |
105 | // listen on the server compilations
106 | serverCompiler.hooks.compile.tap('server', () => {
107 | if (!apiPromiseIsResolved) {
108 | // it has not been resolved so nothing to do yet
109 | return;
110 | }
111 |
112 | // inform we have not resolved instance
113 | apiPromiseIsResolved = false;
114 |
115 | // set the resolve callback as apiPromiseResolve and save the promise itself as apiPromise
116 | // eslint-disable-next-line no-return-assign
117 | apiPromise = new Promise((resolve) => (apiPromiseResolve = resolve));
118 | });
119 |
120 | // watch the server compiler
121 | serverCompiler.watch(watchOptions, (error, stats) => {
122 | if (!error && !stats.hasErrors()) {
123 | try {
124 | // we cannot apply the update so we will reload the whole server
125 | // but first delete the node cache
126 | delete require.cache[require.resolve('../build/server')];
127 |
128 | // now we can get the latest version of our server
129 | // eslint-disable-next-line global-require, import/no-unresolved
130 | api = require('../build/server').default;
131 |
132 | console.warn('[\x1b[35mHot Reload\x1b[0m] Server has been reloaded.');
133 | } catch (runtimeError) {
134 | // print the error
135 | console.error(runtimeError);
136 | }
137 |
138 | apiPromiseIsResolved = true;
139 | apiPromiseResolve();
140 | }
141 | });
142 |
143 | // Wait until both client and server bundles are ready
144 | await clientPromise;
145 | await serverPromise;
146 |
147 | // Launch WebpackDevServer.
148 | devServer.listen(port, host, (err) => {
149 | if (err) {
150 | console.error(err);
151 |
152 | return;
153 | }
154 |
155 | if (isInteractive) {
156 | clearConsole();
157 | }
158 |
159 | console.info(chalk.cyan('Starting the development server...\n'));
160 | openBrowser(urls.localUrlForBrowser);
161 | });
162 |
163 | return devServer;
164 | }
165 |
166 | export default start;
167 |
--------------------------------------------------------------------------------
/tools/webpack.config.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
4 | import TerserPlugin from 'terser-webpack-plugin';
5 | import webpack from 'webpack';
6 | import WebpackAssetsManifest from 'webpack-assets-manifest';
7 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
8 | import nodeExternals from 'webpack-node-externals';
9 | import pkg from '../package.json';
10 | import PackagePlugin from './lib/WebpackPackagePlugin';
11 |
12 | const rootDir = path.resolve(__dirname, '..');
13 | const buildDir = path.join(rootDir, 'build');
14 | const isDebug = process.env.NODE_ENV !== 'production';
15 | const isAnalyze = process.argv.includes('--analyze');
16 |
17 | const getBabelRule = (envPresetOptions) => ({
18 | test: /\.jsx?$/,
19 | include: [
20 | path.join(rootDir, 'app'),
21 | path.join(rootDir, 'api'),
22 | path.join(rootDir, 'tools'),
23 | ],
24 | loader: 'babel-loader',
25 | options: {
26 | // https://github.com/babel/babel-loader#options
27 | cacheDirectory: isDebug,
28 |
29 | // https://babeljs.io/docs/usage/options/
30 | babelrc: false,
31 |
32 | presets: [
33 | // A Babel preset that can automatically determine the Babel plugins and polyfills
34 | // https://github.com/babel/babel-preset-env
35 | ['@babel/preset-env', envPresetOptions],
36 |
37 | // JSX
38 | // https://github.com/babel/babel/tree/master/packages/babel-preset-react
39 | ['@babel/preset-react', { development: isDebug }],
40 | ],
41 |
42 | plugins: [
43 | ['@babel/plugin-transform-runtime', {
44 | corejs: false,
45 | helpers: isDebug,
46 | regenerator: true,
47 | absoluteRuntime: true,
48 | }],
49 | ],
50 | },
51 | });
52 |
53 | const getImageRule = (options) => ({
54 | test: /\.(png|jpg|gif|svg)$/,
55 | rules: [
56 | {
57 | loader: 'file-loader',
58 | options: {
59 | name: '[hash:20].[ext]',
60 | ...options,
61 | },
62 | },
63 | {
64 | loader: 'image-webpack-loader',
65 | },
66 | ],
67 | });
68 |
69 | const sharedRules = [
70 | // Exclude dev modules from production build
71 | !isDebug && {
72 | test: path.resolve(rootDir, 'node_modules/react-deep-force-update/lib/index.js'),
73 | loader: 'null-loader',
74 | },
75 | ].filter(Boolean);
76 |
77 | const config = {
78 | context: rootDir,
79 |
80 | mode: isDebug ? 'development' : 'production',
81 |
82 | output: {
83 | pathinfo: isDebug,
84 | publicPath: '/',
85 | filename: isDebug ? '[name].js' : '[name].[chunkhash:8].js',
86 | chunkFilename: isDebug ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js',
87 | },
88 |
89 | resolve: {
90 | extensions: ['.js', '.jsx', '.json', '.mjs'],
91 | },
92 |
93 | // Don't attempt to continue if there are any errors.
94 | bail: !isDebug,
95 |
96 | cache: isDebug,
97 |
98 | // Specify what bundle information gets displayed
99 | // https://webpack.js.org/configuration/stats/
100 | stats: {
101 | cached: false,
102 | cachedAssets: false,
103 | chunks: false,
104 | chunkModules: false,
105 | colors: true,
106 | hash: false,
107 | modules: false,
108 | reasons: isDebug,
109 | timings: true,
110 | version: false,
111 | },
112 |
113 | // Choose a developer tool to enhance debugging
114 | // https://webpack.js.org/configuration/devtool/#devtool
115 | devtool: isDebug ? 'cheap-module-inline-source-map' : 'source-map',
116 | };
117 |
118 | const styleRules = [
119 | {
120 | oneOf: [
121 | {
122 | loader: 'css-loader',
123 | options: {
124 | localsConvention: 'camelCase',
125 | modules: {
126 | mode: 'pure',
127 | },
128 | },
129 | // only use module style in the directories components & routes
130 | include: [
131 | path.join(rootDir, 'app/components'),
132 | path.join(rootDir, 'app/routes'),
133 | ],
134 | },
135 | { loader: 'css-loader' },
136 | ],
137 | },
138 | { loader: 'sass-loader', test: /\.scss$/ },
139 | ];
140 |
141 | const clientConfig = {
142 | ...config,
143 |
144 | name: 'client',
145 | target: 'web',
146 |
147 | entry: {
148 | client: [
149 | isDebug && 'react-dev-utils/webpackHotDevClient',
150 | './app/index.jsx',
151 | ].filter(Boolean),
152 | },
153 |
154 | output: {
155 | ...config.output,
156 | path: path.join(buildDir, 'public'),
157 | },
158 |
159 | resolve: {
160 | // Webpack mutates resolve object, so clone it to avoid issues
161 | // https://github.com/webpack/webpack/issues/4817
162 | ...config.resolve,
163 | // Allow absolute paths in imports through an alias, e.g. import Button from '@app/components/Button'
164 | alias: {
165 | '@app': path.resolve('./app'),
166 | },
167 | },
168 |
169 | module: {
170 | rules: [
171 | // Rules for JS / JSX
172 | getBabelRule({
173 | targets: { browsers: pkg.browserslist },
174 | // Allow importing core-js in entrypoint and use browserlist to select polyfills
175 | useBuiltIns: 'entry',
176 | // Set the corejs version we are using to avoid warnings in console
177 | // This will need to change once we upgrade to corejs@3
178 | corejs: 3,
179 | // Do not transform modules to CJS
180 | modules: false,
181 | }),
182 |
183 | // Shared rules
184 | ...sharedRules,
185 |
186 | // Style rules
187 | {
188 | test: /\.s?css$/,
189 | rules: [
190 | { loader: isDebug ? 'style-loader' : MiniCssExtractPlugin.loader },
191 | ...styleRules,
192 | ],
193 | },
194 |
195 | // Images
196 | getImageRule(),
197 | ],
198 | },
199 |
200 | plugins: [
201 | // Define free variables
202 | // https://webpack.js.org/plugins/define-plugin/
203 | new webpack.DefinePlugin({ __DEV__: isDebug }),
204 |
205 | // we need to build a manifest for the backend
206 | new WebpackAssetsManifest({
207 | publicPath: true,
208 | writeToDisk: true,
209 | output: path.resolve(buildDir, 'asset-manifest.json'),
210 | done: (manifest, stats) => {
211 | const addPath = (file) => manifest.getPublicPath(file);
212 | // write chunk-manifest.json.json
213 | const chunkFileName = path.resolve(buildDir, 'chunk-manifest.json');
214 |
215 | try {
216 | const chunkFiles = stats.compilation.chunkGroups.reduce((acc, entry) => {
217 | if (!acc[entry.name]) {
218 | // initialize the first time an empty map
219 | acc[entry.name] = { js: [], css: [] };
220 | }
221 |
222 | const entryMap = acc[entry.name];
223 |
224 | // first loop on chunks
225 | for (const chunk of entry.chunks) {
226 | // then on files for each chunk
227 | for (const file of chunk.files) {
228 | if (file.endsWith('.hot-update.js')) {
229 | continue;
230 | }
231 |
232 | if (file.endsWith('.js')) {
233 | entryMap.js.push(addPath(file));
234 | }
235 |
236 | if (file.endsWith('.css')) {
237 | entryMap.css.push(addPath(file));
238 | }
239 | }
240 | }
241 |
242 | return acc;
243 | }, {});
244 |
245 | // write it on the disk
246 | fs.writeFileSync(chunkFileName, JSON.stringify(chunkFiles, null, 2));
247 | } catch (err) {
248 | console.error(`ERROR: Cannot write ${chunkFileName}: `, err);
249 |
250 | if (!isDebug) {
251 | // exit for production, it's critical
252 | process.exit(1);
253 | }
254 | }
255 | },
256 | }),
257 |
258 | !isDebug && new MiniCssExtractPlugin({ filename: '[contenthash].css' }),
259 |
260 | // Hot Module Replacement plugin
261 | isDebug && new webpack.HotModuleReplacementPlugin(),
262 |
263 | // Webpack Bundle Analyzer
264 | // https://github.com/th0r/webpack-bundle-analyzer
265 | isDebug && isAnalyze && new BundleAnalyzerPlugin(),
266 | ].filter(Boolean),
267 |
268 | // Move modules that occur in multiple entry chunks to a new entry chunk (the commons chunk).
269 | optimization: {
270 | minimize: !isDebug,
271 | minimizer: [
272 | // This is only used in production mode
273 | new TerserPlugin({
274 | terserOptions: {
275 | parse: {
276 | // we want terser to parse ecma 8 code. However, we don't want it
277 | // to apply any minification steps that turns valid ecma 5 code
278 | // into invalid ecma 5 code. This is why the 'compress' and 'output'
279 | // sections only apply transformations that are ecma 5 safe
280 | // https://github.com/facebook/create-react-app/pull/4234
281 | ecma: 8,
282 | },
283 | compress: {
284 | ecma: 5,
285 | warnings: false,
286 | // Disabled because of an issue with Uglify breaking seemingly valid code:
287 | // https://github.com/facebook/create-react-app/issues/2376
288 | // Pending further investigation:
289 | // https://github.com/mishoo/UglifyJS2/issues/2011
290 | comparisons: false,
291 | // Disabled because of an issue with Terser breaking valid code:
292 | // https://github.com/facebook/create-react-app/issues/5250
293 | // Pending further investigation:
294 | // https://github.com/terser-js/terser/issues/120
295 | inline: 2,
296 | },
297 | mangle: {
298 | safari10: true,
299 | },
300 | output: {
301 | ecma: 5,
302 | comments: false,
303 | // Turned on because emoji and regex is not minified properly using default
304 | // https://github.com/facebook/create-react-app/issues/2488
305 | ascii_only: true,
306 | },
307 | },
308 |
309 | // Enable file caching
310 | cache: true,
311 | sourceMap: true,
312 | }),
313 | ],
314 |
315 | // Automatically split vendor and commons
316 | // https://twitter.com/wSokra/status/969633336732905474
317 | // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
318 | splitChunks: {
319 | chunks: 'all',
320 | name: false,
321 | },
322 |
323 | // Keep the runtime chunk separated to enable long term caching
324 | // https://twitter.com/wSokra/status/969679223278505985
325 | runtimeChunk: true,
326 | },
327 |
328 | // Some libraries import Node modules but don't use them in the browser.
329 | // Tell Webpack to provide empty mocks for them so importing them works.
330 | // https://webpack.js.org/configuration/node/
331 | // https://github.com/webpack/node-libs-browser/tree/master/mock
332 | node: {
333 | fs: 'empty',
334 | net: 'empty',
335 | tls: 'empty',
336 | },
337 | };
338 |
339 | const serverConfig = {
340 | ...config,
341 |
342 | name: 'server',
343 | target: 'node',
344 |
345 | entry: {
346 | server: ['./api/index.js'],
347 | },
348 |
349 | output: {
350 | ...config.output,
351 | path: buildDir,
352 | filename: '[name].js',
353 | chunkFilename: 'chunks/[name].js',
354 | libraryTarget: 'commonjs2',
355 |
356 | ...isDebug && {
357 | hotUpdateMainFilename: 'updates/[hash].hot-update.json',
358 | hotUpdateChunkFilename: 'updates/[id].[hash].hot-update.js',
359 | },
360 | },
361 |
362 | resolve: {
363 | // Webpack mutates resolve object, so clone it to avoid issues
364 | // https://github.com/webpack/webpack/issues/4817
365 | ...config.resolve,
366 | // Allow absolute paths in imports through an alias, e.g. import Button from '@app/components/Button'
367 | alias: {
368 | '@api': path.resolve('./api'),
369 | },
370 | },
371 |
372 | module: {
373 | rules: [
374 | // Rules for JS / JSX
375 | getBabelRule({
376 | targets: { node: pkg.engines.node.match(/(\d+\.?)+/)[0] },
377 | modules: false,
378 | useBuiltIns: false,
379 | debug: false,
380 | }),
381 |
382 | // Shared rules
383 | ...sharedRules,
384 |
385 | // Images
386 | getImageRule({ emitFile: false }),
387 | ],
388 | },
389 |
390 | plugins: [
391 | // Define free variables
392 | // https://webpack.js.org/plugins/define-plugin/
393 | new webpack.DefinePlugin({ __DEV__: isDebug }),
394 |
395 | // Adds a banner to the top of each generated chunk
396 | // https://webpack.js.org/plugins/banner-plugin/
397 | new webpack.BannerPlugin({
398 | banner: 'require("source-map-support").install();',
399 | raw: true,
400 | entryOnly: false,
401 | }),
402 |
403 | !isDebug && new PackagePlugin({
404 | additionalModules: ['source-map-support', 'sequelize-cli'],
405 | }),
406 | ].filter(Boolean),
407 |
408 | externals: [
409 | './asset-manifest.json',
410 | './chunk-manifest.json',
411 | nodeExternals(),
412 | ],
413 |
414 | // Do not replace node globals with polyfills
415 | // https://webpack.js.org/configuration/node/
416 | node: {
417 | console: false,
418 | global: false,
419 | process: false,
420 | Buffer: false,
421 | __filename: false,
422 | __dirname: false,
423 | },
424 | };
425 |
426 | export default [clientConfig, serverConfig];
427 |
--------------------------------------------------------------------------------