├── .babelrc ├── .circleci └── config.yml ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── build.yml │ ├── ncu.yml │ └── testing.yml ├── .gitignore ├── .huskyrc ├── .hygen.js ├── .hygen └── generate │ ├── component │ ├── component.test.tsx.t │ ├── component.tsx.t │ └── prompt.js │ └── redux │ ├── action.test.ts.t │ ├── action.ts.t │ ├── actionType.ts.t │ ├── inject_actionTypes.ts.t │ ├── inject_actions.ts.t │ ├── inject_actions2.ts.t │ ├── inject_constant.ts.t │ ├── inject_reducers.ts.t │ ├── inject_stateTypes.ts.t │ ├── prompt.js │ ├── reducer.test.ts.t │ └── reducer.ts.t ├── .node-version ├── .prettierrc ├── LICENSE ├── README.md ├── docker-compose.yml ├── docker ├── app │ └── Dockerfile └── web │ ├── Dockerfile │ └── nginx │ └── default.conf ├── docs └── images │ ├── flux.png │ └── type.png ├── jest.config.js ├── jest.tsconfig.json ├── next-env.d.ts ├── next.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── api │ ├── account.ts │ ├── signin.ts │ ├── signout.ts │ └── ua.ts ├── index.tsx ├── signin.tsx └── todo.tsx ├── server └── index.ts ├── setupJestFetchMock.js ├── src ├── __mocks__ │ └── next │ │ └── config.ts ├── actionTypes │ ├── account.ts │ ├── counter.ts │ └── index.ts ├── actions │ ├── account │ │ ├── __test__ │ │ │ └── account.test.ts │ │ └── index.ts │ ├── counter │ │ ├── __test__ │ │ │ └── counter.test.ts │ │ └── index.ts │ └── index.ts ├── components │ ├── Layout │ │ ├── __test__ │ │ │ └── Layout.test.tsx │ │ └── index.tsx │ ├── Nav │ │ ├── __test__ │ │ │ └── Nav.test.tsx │ │ └── index.tsx │ ├── Signout │ │ ├── __test__ │ │ │ └── index.test.tsx │ │ └── index.tsx │ └── TodoLists │ │ ├── __test__ │ │ └── TodoLists.test.tsx │ │ └── index.tsx ├── config │ └── index.ts ├── constant.ts ├── hooks │ ├── signin │ │ ├── __test__ │ │ │ ├── useEmail.test.ts │ │ │ └── usePassword.test.ts │ │ ├── useEmail.ts │ │ └── usePassword.ts │ └── todo │ │ ├── __test__ │ │ ├── useTodo.test.ts │ │ └── useTodoList.test.ts │ │ ├── index.ts │ │ ├── useTodo.ts │ │ └── useTodoList.ts ├── lib │ ├── http │ │ └── index.ts │ ├── testing │ │ └── index.tsx │ └── ua │ │ ├── __test__ │ │ └── ua.test.ts │ │ └── index.ts ├── modelTypes.ts ├── pages │ └── __test__ │ │ └── todo.test.tsx ├── reducers │ ├── account │ │ ├── __test__ │ │ │ ├── __snapshots__ │ │ │ │ └── account.test.ts.snap │ │ │ └── account.test.ts │ │ └── index.ts │ ├── counter │ │ ├── __test__ │ │ │ ├── __snapshots__ │ │ │ │ └── counter.test.ts.snap │ │ │ └── counter.test.ts │ │ └── index.ts │ ├── index.ts │ └── reducers.ts ├── sagas │ ├── __test__ │ │ ├── account.test.ts │ │ └── counter.test.ts │ ├── index.ts │ ├── selectors │ │ ├── account │ │ │ └── index.ts │ │ └── counter │ │ │ └── index.ts │ └── tasks │ │ ├── account │ │ └── index.ts │ │ └── counter │ │ └── index.ts ├── service │ └── auth │ │ ├── __test__ │ │ └── auth.test.ts │ │ └── index.ts ├── stateTypes.ts └── store │ └── index.ts ├── tsconfig.json ├── tsconfig.server.json └── types └── index.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "module-resolver", { 5 | "alias": { 6 | "~": "./src" 7 | } 8 | } 9 | ] 10 | ], 11 | "env": { 12 | "development": { 13 | "presets": [ 14 | "next/babel" 15 | ], 16 | "plugins": [ 17 | "styled-jsx/babel-test" 18 | ] 19 | }, 20 | "production": { 21 | "presets": [ 22 | "next/babel" 23 | ], 24 | "plugins": [ 25 | "styled-jsx/babel-test" 26 | ] 27 | }, 28 | "test": { 29 | "presets": [ 30 | "next/babel" 31 | ], 32 | "plugins": [ 33 | "styled-jsx/babel-test" 34 | ] 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | aws-ecr: circleci/aws-ecr@6.8.2 5 | node: circleci/node@2.0 6 | 7 | workflows: 8 | test: 9 | jobs: 10 | - node/test: 11 | version: 12.16.3 12 | filters: 13 | branches: 14 | ignore: master 15 | 16 | build_and_push_image: 17 | jobs: 18 | - node/test: 19 | version: 12.16.3 20 | filters: 21 | branches: 22 | only: master 23 | 24 | - request-build_and_push: 25 | requires: 26 | - node/test 27 | type: approval 28 | 29 | - aws-ecr/build-and-push-image: 30 | name: aws-ecr/build-and-push-app-image 31 | requires: 32 | - request-build_and_push 33 | context: ecr-sample 34 | account-url: AWS_ECR_ACCOUNT_URL 35 | aws-access-key-id: AWS_ACCESS_KEY_ID 36 | aws-secret-access-key: AWS_SECRET_ACCESS_KEY 37 | region: AWS_REGION 38 | repo: "${REPO_NAME}" 39 | tag: "latest-app" 40 | dockerfile: "./docker/app/Dockerfile" 41 | 42 | - aws-ecr/build-and-push-image: 43 | name: aws-ecr/build-and-push-web-image 44 | requires: 45 | - request-build_and_push 46 | context: ecr-sample 47 | account-url: AWS_ECR_ACCOUNT_URL 48 | aws-access-key-id: AWS_ACCESS_KEY_ID 49 | aws-secret-access-key: AWS_SECRET_ACCESS_KEY 50 | region: AWS_REGION 51 | repo: "${REPO_NAME}" 52 | tag: "latest-web" 53 | path: "./docker/web" 54 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | # Hygen Template 16 | [*.t] 17 | insert_final_newline = false 18 | 19 | [{*yml, *.yaml}] 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/*.js 3 | .next -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json", 5 | "tsconfigRootDir": "." 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "react" 10 | ], 11 | "env": { 12 | "es6": true, 13 | "browser": true 14 | }, 15 | "extends": [ 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:react/recommended", 18 | "prettier" 19 | ], 20 | "rules": { 21 | "@typescript-eslint/explicit-function-return-type": [ 22 | "off", 23 | { "allowExpressions": true } 24 | ], 25 | "@typescript-eslint/explicit-member-accessibility": [ 26 | "error", 27 | { "accessibility": "no-public" } 28 | ], 29 | "@typescript-eslint/explicit-module-boundary-types": [ 30 | "off", 31 | { "allowTypedFunctionExpressions": false } 32 | ], 33 | "react/prop-types": [ 34 | "off" 35 | ], 36 | "@typescript-eslint/naming-convention" :[ 37 | "error", 38 | { 39 | "selector": "default", 40 | "format": ["camelCase"] 41 | }, 42 | { 43 | "selector": "variable", 44 | "format": ["PascalCase", "camelCase", "UPPER_CASE"], 45 | "leadingUnderscore": "allow" 46 | }, 47 | { 48 | "selector": "property", 49 | "format": ["PascalCase", "camelCase", "UPPER_CASE", "snake_case"] 50 | }, 51 | { 52 | "selector": "parameter", 53 | "format": ["PascalCase", "camelCase"], 54 | "leadingUnderscore": "allow" 55 | }, 56 | { 57 | "selector": "typeLike", 58 | "format": ["PascalCase"] 59 | } 60 | ] 61 | }, 62 | "settings": { 63 | "react": { 64 | "version": "detect" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build and push images to ECR 2 | on: 3 | push: 4 | branches: 5 | - 'hogehoge' # Enter any branch name 6 | 7 | jobs: 8 | build: 9 | name: build and push docker image 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v1 14 | 15 | - name: Configure AWS Credentials 16 | uses: aws-actions/configure-aws-credentials@v1 17 | with: 18 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 19 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 20 | aws-region: ${{ secrets.AWS_REGION }} 21 | 22 | - name: Login to Amazon ECR 23 | id: login-ecr 24 | uses: aws-actions/amazon-ecr-login@v1 25 | 26 | - name: Run build and Push images 27 | env: 28 | ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }} 29 | ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} 30 | run: | 31 | docker build --target production -t $ECR_REGISTRY/$ECR_REPOSITORY:latest-app -f docker/app/Dockerfile . 32 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest-app 33 | 34 | - name: Run build and Push Web image 35 | env: 36 | ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }} 37 | ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} 38 | run: | 39 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest-app -f docker/web/Dockerfile . 40 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest-web 41 | -------------------------------------------------------------------------------- /.github/workflows/ncu.yml: -------------------------------------------------------------------------------- 1 | name: ncu 2 | on: 3 | pull_request: 4 | type: [opened] 5 | 6 | jobs: 7 | ncu: 8 | name: npm check updates 9 | runs-on: macOS-latest 10 | strategy: 11 | matrix: 12 | node_version: [12] 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v1 16 | - name: Setup node ${{ matrix.node_version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node_version }} 20 | 21 | - name: Install ncu 22 | run: | 23 | npm install -D npm-check-updates 24 | 25 | - name: Report PR comment 26 | uses: actions/github-script@v3 27 | with: 28 | github-token: ${{secrets.GITHUB_TOKEN}} 29 | script: | 30 | const pr = context.payload.pull_request; 31 | if (!pr) { 32 | console.log('github.context.payload.pull_request not exist'); 33 | return; 34 | } 35 | 36 | const ncu = require(`${process.env.GITHUB_WORKSPACE}/node_modules/npm-check-updates/lib/index.js`); 37 | const upgraded = await ncu.run(); 38 | 39 | let bodyMessage; 40 | if (Object.keys(upgraded).length > 0) { 41 | bodyMessage = `⚠️ Check dependencies to upgrade: ${JSON.stringify( 42 | upgraded 43 | )}`; 44 | } else { 45 | bodyMessage = '👍 All the latest modules 🙆‍♀️'; 46 | } 47 | 48 | await github.issues.createComment({ 49 | issue_number: context.issue.number, 50 | owner: context.repo.owner, 51 | repo: context.repo.repo, 52 | body: bodyMessage 53 | }) -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: testing 2 | on: push 3 | jobs: 4 | test: 5 | name: Node ${{ matrix.node_version }} testing 6 | runs-on: macOS-latest 7 | strategy: 8 | matrix: 9 | node_version: [12] 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v1 13 | - name: Setup node ${{ matrix.node_version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node_version }} 17 | 18 | - name: Run npm install and testing 19 | run: | 20 | npm install 21 | npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # next.js build output 5 | .next 6 | dist 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # System Files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # VS code 20 | .vscode 21 | 22 | # WebStorm 23 | .idea 24 | 25 | # jest 26 | coverage 27 | 28 | # Environment Variables 29 | .env 30 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.hygen.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | templates: `${__dirname}/.hygen` 3 | } 4 | -------------------------------------------------------------------------------- /.hygen/generate/component/component.test.tsx.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: 'src/<%= type === "pages" ? `pages/__test__/${name}` : `components/${h.changeCase.pascal(name)}/__test__/${h.changeCase.pascal(name)}` %>.test.tsx' 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | Props = Name + 'Props'; 7 | -%> 8 | /* eslint-env jest */ 9 | import * as React from 'react'; 10 | import { render } from '@testing-library/react'; 11 | 12 | import <%= Name %>, { <%= Props %> } from '~/components/<%= Name %>'; 13 | 14 | describe('<%= name %>', () => { 15 | let props: <%= Props %>; 16 | 17 | // Initialize 18 | beforeEach(() => { 19 | props = {}; 20 | }); 21 | 22 | it('renders without errors', () => { 23 | const { container } = render(<<%= Name %> {...props} />); 24 | expect(container).not.toBeNull(); 25 | }); 26 | }); -------------------------------------------------------------------------------- /.hygen/generate/component/component.tsx.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: '<%= type === "pages" ? `${type}/${name}.tsx` : `src/components/${h.changeCase.pascal(name)}/index.tsx` %>' 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | Props = Name + 'Props'; 7 | -%> 8 | import * as React from 'react'; 9 | 10 | export type <%= Props %> = {}; 11 | 12 | const <%= Name %>: React.FC<<%= Props %>> = () => { 13 | return
Functional Component
; 14 | }; 15 | 16 | export default <%= Name %>; -------------------------------------------------------------------------------- /.hygen/generate/component/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'select', 4 | name: 'type', 5 | message: 'What is the component type?', 6 | choices: ['pages', 'default'] 7 | }, 8 | { 9 | type: 'input', 10 | name: 'name', 11 | message: 'What is the component name?', 12 | validate: answer => { 13 | if (answer !== '') { 14 | return true; 15 | } 16 | } 17 | } 18 | ]; 19 | -------------------------------------------------------------------------------- /.hygen/generate/redux/action.test.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actions/<%= name %>/__test__/<%= name %>.test.ts 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | NAME = h.changeCase.upper(name); 7 | Actions = name + 'Actions'; 8 | State = Name + 'State'; 9 | -%> 10 | /* eslint-env jest */ 11 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant'; 12 | import { <%= Actions %> } from '~/actions'; 13 | import { <%= State %> } from '~/stateTypes'; 14 | 15 | describe('<%= Name %> actions', () => { 16 | it('should create get <%= name %> action', () => { 17 | const expectedAction = { 18 | type: GET_<%= NAME %> 19 | }; 20 | 21 | expect(<%= Actions %>.get<%= Name %>()).toEqual(expectedAction); 22 | }); 23 | 24 | it('should create update <%= name %> action', () => { 25 | const expectedPayload: <%= State %> = { 26 | value: 'hoge' 27 | }; 28 | const expectedAction = { 29 | type: UPDATE_<%= NAME %>, 30 | payload: { 31 | value: expectedPayload.value 32 | } 33 | }; 34 | 35 | expect(<%= Actions %>.update<%= Name %>(expectedPayload)).toEqual( 36 | expectedAction 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /.hygen/generate/redux/action.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actions/<%= name %>/index.ts 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | NAME = h.changeCase.upper(name); 7 | ActionTypes = Name + 'ActionTypes'; 8 | State = Name + 'State'; 9 | -%> 10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant'; 11 | import { <%= ActionTypes %> } from '~/actionTypes'; 12 | import { <%= State %> } from '~/stateTypes'; 13 | 14 | export const get<%= Name %> = (): <%= ActionTypes %> => ({ 15 | type: GET_<%= NAME %> 16 | }); 17 | 18 | export const update<%= Name %> = (payload: <%= State %>): <%= ActionTypes %> => ({ 19 | type: UPDATE_<%= NAME %>, 20 | payload, 21 | }); 22 | -------------------------------------------------------------------------------- /.hygen/generate/redux/actionType.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actionTypes/<%= name %>.ts 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | NAME = h.changeCase.upper(name); 7 | ActionTypes = Name + 'ActionTypes'; 8 | Action = Name + 'Action'; 9 | -%> 10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant'; 11 | import { <%= Name %>State } from '~/stateTypes'; 12 | 13 | interface Get<%= Action %> { 14 | type: typeof GET_<%= NAME %>; 15 | } 16 | 17 | interface Update<%= Action %> { 18 | type: typeof UPDATE_<%= NAME %>; 19 | payload: <%= Name %>State; 20 | } 21 | 22 | export type <%= ActionTypes %> = Get<%= Action %> | Update<%= Action %>; 23 | -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_actionTypes.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actionTypes/index.ts 3 | inject: true 4 | append: true 5 | skip_if: <%= name %> 6 | --- 7 | export * from './<%= name %>'; -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_actions.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actions/index.ts 3 | inject: true 4 | prepend: true 5 | skip_if: './<%= name %>' 6 | --- 7 | import * as <%= name %>Actions from './<%= name %>'; -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_actions2.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/actions/index.ts 3 | inject: true 4 | after: 'export {' 5 | skip_if: '<%= name %>Actions,' 6 | --- 7 | <%= name %>Actions, -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_constant.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/constant.ts 3 | inject: true 4 | append: true 5 | skip_if: <%= h.changeCase.upper(name) %> 6 | --- 7 | <% NAME = h.changeCase.upper(name) -%> 8 | 9 | export const GET_<%= NAME %> = 'GET_<%= NAME %>'; 10 | export const UPDATE_<%= NAME %> = 'UPDATE_<%= NAME %>'; -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_reducers.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/reducers/reducers.ts 3 | inject: true 4 | append: true 5 | skip_if: <%= name %> 6 | --- 7 | export { <%= name %> } from '~/reducers/<%= name %>'; -------------------------------------------------------------------------------- /.hygen/generate/redux/inject_stateTypes.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/stateTypes.ts 3 | inject: true 4 | append: true 5 | skip_if: <%= h.changeCase.pascal(name) %> 6 | --- 7 | 8 | export interface <%= h.changeCase.pascal(name) %>State { 9 | value: string; 10 | } -------------------------------------------------------------------------------- /.hygen/generate/redux/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'What is the base name of reducer, action?', 6 | validate: answer => { 7 | if (answer !== '') { 8 | return true; 9 | } 10 | } 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /.hygen/generate/redux/reducer.test.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/reducers/<%= name %>/__test__/<%= name %>.test.ts 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | State = Name + 'State'; 7 | -%> 8 | import snapshotDiff from 'snapshot-diff'; 9 | import { <%= name %>Actions } from '~/actions'; 10 | 11 | import { initial<%= Name %>, <%= name %> } from '~/reducers/<%= name %>'; 12 | import { <%= State %> } from '~/stateTypes'; 13 | 14 | const testing<%= Name %>: <%= State %> = { 15 | value: 'hoge' 16 | }; 17 | 18 | describe('<%= Name %> reducer', () => { 19 | it('update <%= name %>', () => { 20 | expect( 21 | snapshotDiff( 22 | initial<%= Name %>, 23 | <%= name %>(initial<%= Name %>, <%= name %>Actions.update<%= Name %>(testing<%= Name %>)) 24 | ) 25 | ).toMatchSnapshot(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.hygen/generate/redux/reducer.ts.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/reducers/<%= name %>/index.ts 3 | --- 4 | <% 5 | Name = h.changeCase.pascal(name); 6 | NAME = h.changeCase.upper(name); 7 | ActionTypes = Name + 'ActionTypes'; 8 | State = Name + 'State'; 9 | -%> 10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant'; 11 | import { <%= ActionTypes %> } from '~/actionTypes'; 12 | import { <%= State %> } from '~/stateTypes'; 13 | 14 | export const initial<%= Name %>: <%= State %> = { 15 | value: '' 16 | }; 17 | 18 | export const <%= name %> = ( 19 | state = initial<%= Name %>, 20 | action: <%= ActionTypes %> 21 | ): <%= State %> => { 22 | switch (action.type) { 23 | case GET_<%= NAME %>: 24 | return state; 25 | 26 | case UPDATE_<%= NAME %>: 27 | return { ...state, ...action.payload }; 28 | 29 | default: 30 | return state; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 12.16.3 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": true, 4 | "singleQuote": true, 5 | "useTabs": false, 6 | "tabWidth": 4, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kotaro Chiba, Hiroaki Sasaki 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js with Typescript 2 | [Next.js](https://nextjs.org/) simple boiler plate. 3 | 4 | - [Motivation](#motivation) 5 | 1. [Practical](#1-practical) 6 | 2. [Simple](#2-simple) 7 | 3. [Type annotation](#3-type-annotation) 8 | - [Spec](#wpec) 9 | - [Directory](#directory) 10 | - [Environment](#environment) 11 | - [How to use](#how-to-use) 12 | - [Test policy](#test-policy) 13 | - [Component](#component) 14 | - [Redux / Redux-saga](#redux--redux-saga) 15 | - [Others](#others) 16 | 17 | 18 | ## Motivation 19 | ### 1. Practical 20 | Most of Web FrontEnd App need a web server. 21 | This project deferred node.js (Koa.js) web server is prepared. 22 | 23 | ### 2. Simple 24 | As mush as possible simple archtecture. 25 | It is clear what you are doing by looking at the code. 26 | 27 | #### Flux 28 | No business logic is brought into Action and Reducer. 29 | Business logic concentrates on Saga tasks. 30 | 31 | ##### Action 32 | - Naming "actually happend" 33 | - As a result, REST-like naming 34 | - No operation payload 35 | - Action has two opreration 36 | 1. Start any redux-saga task 37 | 2. Deliver `Action` type and payload to reducer 38 | 39 | ##### Reducer 40 | - Only do the work of updating which state for each `Action` 41 | - No operation payload 42 | 43 | ##### Middleware (Redux-saga) 44 | - Always start a task with an `Action` 45 | - End the task with `Action`, or let the `routing process` finish the task 46 | - Asynchronous processing uses the call API 47 | - Can operation payload (※ keep the following promise) 48 | 1. It is possible to take a value from `State` (using select API) 49 | 2. It is also possible to manipulate the value retrieved from `State` in the task 50 | 3. However, state must be updated via `Action => Reducer` (does not update `State` directly with the manipulated value) 51 | 52 | 53 | 54 | ![flux flow](./docs/images/flux.png) 55 | 56 | ### 3. Type annotation 57 | - Use typescript 58 | 59 | ## Spec 60 | - Application 61 | - Next.js 62 | - HttpClient 63 | - [Isomorphic-unfetch](https://github.com/developit/unfetch/tree/master/packages/isomorphic-unfetch) 64 | - Flux 65 | - [Redux](https://github.com/reduxjs/redux) 66 | - Middleware 67 | - [Redux-saga](https://github.com/redux-saga/redux-saga) 68 | - Web server 69 | - [Koa.js](https://github.com/koajs/koa) 70 | - Linter 71 | - [Eslint](https://github.com/eslint/eslint) 72 | - Code format 73 | - [Prettier](https://github.com/prettier/prettier) 74 | - Pre commit 75 | - [Husky](https://github.com/typicode/husky) 76 | - [Lint-staged](https://github.com/okonet/lint-staged) 77 | - Language 78 | - [Typescript](https://www.typescriptlang.org/) 79 | - Logging 80 | - [Bunyan](https://github.com/trentm/node-bunyan) 81 | - Web server logger 82 | - Unit test 83 | - [Jest](https://github.com/facebook/jest) 84 | - [Testing-library](https://github.com/testing-library/react-testing-library) 85 | - Component testing 86 | - [Snapshot-diff](https://github.com/jest-community/snapshot-diff) 87 | - Reducer testing 88 | - [Redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan) 89 | - Saga task testing 90 | - Code Generator 91 | - [Hygen](http://www.hygen.io/) 92 | - Generating code of basic component, redux actions and reducers 93 | 94 | ## Directory 95 | ``` 96 | . 97 | ├─ docker # 98 | │ ├─ app # FrontEnd App (multi stage build) 99 | │ └─ web # nginx (proxy) 100 | │ 101 | ├─ pages # web pages 102 | │ └─ api # BFF api 103 | │ 104 | ├─ server # web server 105 | │ 106 | └─ src # 107 | ├─ actions # redux: action 108 | ├─ components # react: component 109 | ├─ hooks # react: hooks 110 | ├─ pages # only next.js pages testing 111 | ├─ reducers # redux: reducer 112 | ├─ sagas # redux[middleware]: redux-saga 113 | ├─ service # libs | utils 114 | └─ store # redux: configure store 115 | ``` 116 | The testing directory is distributed in parallel with the directory that has each function. 117 | ``` 118 | . 119 | ├─ hoge 120 | │ ├─ __test__ # hoge testing directory 121 | ``` 122 | 123 | ## Environment 124 | ``` 125 | - node.js >= 12.14.1 126 | - next.js >= 9.3.1 127 | - docker 128 | - engine >= 19.03.x 129 | - compose >= 3.6 130 | ``` 131 | 132 | ## How to use 133 | ### Quick development start 134 | ``` 135 | 1. npm i install 136 | 2. docker-compose up 137 | 138 | > http://localhost(:80) 139 | ``` 140 | 141 | This project use nginx for reverse proxy and static resource cache control. 142 | 143 | ``` 144 | localhost:80 localhost:3000 145 | ----------- ---------------- 146 | --> | nginx | --> | app | 147 | | [proxy] | | [next + koa] | 148 | ----------- ---------------- 149 | ``` 150 | 151 | ### Code Generator 152 | you may create React Component / Redux Action, Reducers from Hygen template. 153 | 154 | ```shell script 155 | npm run gen component 156 | npm run gen redux 157 | ``` 158 | 159 | ### Deployment to ECR 160 | You can choose from two methods 161 | 162 | - Github Actions 163 | - CircleCI 164 | 165 | ## Test policy 166 | ### Component 167 | Mainly use [testing-library/react](https://github.com/testing-library/react-testing-library), [react-hooks](https://github.com/testing-library/react-hooks-testing-library). 168 | 169 | #### Checkpoints 170 | 1. After fire event, compare target state or props differences 171 | 2. With or without props, examine the state of the output HTMLElement contained by Component 172 | 173 | ### Redux / Redux-saga 174 | #### Action [priority: *LOW*] 175 | Normaly **simple unit testing**. 176 | Because of actions is pure function and have types for I/O, 177 | If types is safe, there is without having to do little testing. 178 | 179 | #### Reducer [priority: *MIDDLE*] 180 | Reducer testing have two types, **unit** and **snapshot** testing. 181 | Unit testing is compare initial state to updated state. 182 | Snapshot testing is check the difference visually for target state. 183 | 184 | ##### Why do snapshot testing ? 185 | The reducer updates the state (object) on the defined reducer scope for any action. 186 | In other words, it is necessary to check the change of state only for the variation of action, and it is not realistic to cover by comparing the values. 187 | Therefore, we propose to perform a regression test for any action. 188 | If it is a regression, you can always check the state of the state visually with a pull request. 189 | If the specs change, the team will be able to discuss the changes in the PR. 190 | No need to consider values in regression tests. Just see what the reducer changes the state with the code. 191 | 192 | #### Redux-saga [priority: *HIGH*] 193 | Redux-saga testing is integration test using [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan). 194 | If there is a unit test that can be separately performed during the task, let's do that as a separate test. 195 | Since the I/O of the task is guaranteed by the benefits of TypeScript, it is recommended that you evaluate whether the actual processing of the task is as intended. 196 | 197 | ### Others 198 | Basically, do a unit test. 199 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: ./docker/app/Dockerfile 8 | target: development 9 | args: 10 | BUILD_ENV: development 11 | command: sh -c "npm install && npm run dev" 12 | volumes: 13 | - .:/app:delegated 14 | - node_modules:/app/node_modules:delegated 15 | environment: 16 | API_BASE_URL: http://localhost 17 | 18 | nginx: 19 | build: ./docker/web 20 | ports: 21 | - 80:80 22 | depends_on: 23 | - app 24 | restart: on-failure 25 | 26 | volumes: 27 | node_modules: 28 | driver: 'local' -------------------------------------------------------------------------------- /docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | #================= 2 | # development image 3 | #================= 4 | FROM node:12.14.1-alpine AS development 5 | 6 | WORKDIR /app 7 | 8 | COPY package*.json ./ 9 | RUN npm install -g npm && \ 10 | npm install 11 | 12 | ARG BUILD_ENV=production 13 | ENV NODE_ENV=${BUILD_ENV} 14 | 15 | COPY . ./ 16 | 17 | EXPOSE 3000 18 | 19 | #================= 20 | # production image 21 | # CI building test image 22 | #================= 23 | FROM node:12.14.1-alpine AS builder 24 | 25 | WORKDIR /app 26 | 27 | COPY --from=development ./app . 28 | 29 | RUN npm run build 30 | 31 | #================= 32 | FROM node:12.14.1-alpine AS production 33 | 34 | WORKDIR /app 35 | 36 | COPY --from=builder ./app/package.json . 37 | COPY --from=builder ./app/package-lock.json . 38 | COPY --from=builder ./app/next.config.js . 39 | COPY --from=builder ./app/.next ./.next 40 | COPY --from=builder ./app/dist ./dist 41 | 42 | RUN npm install -g npm && \ 43 | npm install --production 44 | 45 | ENV NODE_ENV=production 46 | 47 | EXPOSE 3000 48 | 49 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /docker/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.17-alpine 2 | 3 | ADD ./nginx/default.conf /etc/nginx/conf.d/default.conf 4 | 5 | EXPOSE 80 6 | 7 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /docker/web/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | access_log off; 6 | error_log off; 7 | 8 | gzip on; 9 | gzip_types text/css text/javascript application/javascript application/json image/gif image/png image/jpeg image/svg+xml; 10 | 11 | server_tokens off; 12 | 13 | # static resources cache 14 | location ~ .*\.(jpe?g|gif|png|ico) { 15 | expires 1d; 16 | access_log off; 17 | } 18 | 19 | # upstream 20 | location / { 21 | proxy_pass http://app:3000; 22 | proxy_redirect off; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Proto $scheme; 27 | proxy_read_timeout 1m; 28 | proxy_connect_timeout 1m; 29 | } 30 | } -------------------------------------------------------------------------------- /docs/images/flux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uruha/next-with-ts/94aae9774660f748f0355a9af8a91729396fadf9/docs/images/flux.png -------------------------------------------------------------------------------- /docs/images/type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uruha/next-with-ts/94aae9774660f748f0355a9af8a91729396fadf9/docs/images/type.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const TEST_REGEX = '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$'; 2 | 3 | module.exports = { 4 | testRegex: TEST_REGEX, 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | }, 8 | testPathIgnorePatterns: [ 9 | '/.next/', 10 | '/out/', 11 | '/node_modules/', 12 | '/dist/' 13 | ], 14 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 15 | collectCoverage: true, 16 | moduleNameMapper: { 17 | '^~/(.*)$': '/src/$1', 18 | '^~pages/(.*)$': '/pages/$1' 19 | }, 20 | globals: { 21 | 'ts-jest': { 22 | babelConfig: true, 23 | tsconfig: 'jest.tsconfig.json' 24 | } 25 | }, 26 | automock: false, 27 | setupFiles: ['./setupJestFetchMock.js'] 28 | }; 29 | -------------------------------------------------------------------------------- /jest.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "jsx": "react", 5 | "lib": [ 6 | "es2019", 7 | "dom" 8 | ], 9 | "module": "commonjs", 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "preserveConstEnums": true, 16 | "removeComments": true, 17 | "skipLibCheck": true, 18 | "sourceMap": false, 19 | "strict": true, 20 | "target": "es2019", 21 | "baseUrl": "./", 22 | "paths": { 23 | "~/*": ["src/*"], 24 | "~pages/*": ["pages/*"] 25 | }, 26 | "rootDirs": ["./pages", "./src"], 27 | "typeRoots": [ 28 | "node_modules/@types", 29 | "./types" 30 | ] 31 | }, 32 | "exclude": [ 33 | "node_modules", 34 | "out", 35 | ".next" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { API_BASE_URL } = process.env; 2 | 3 | module.exports = { 4 | poweredByHeader: false, 5 | publicRuntimeConfig: { 6 | API_BASE_URL 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server"], 3 | "exec": "ts-node --project tsconfig.server.json server/index.ts", 4 | "ext": "js ts" 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-with-ts", 3 | "version": "0.1.0", 4 | "description": "next.js with typescript boiler plate", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:uruha/next-with-ts.git" 8 | }, 9 | "scripts": { 10 | "dev": "nodemon", 11 | "build": "next build && tsc --project tsconfig.server.json", 12 | "start": "cross-env NODE_ENV=production node dist/index.js", 13 | "type:check": "tsc", 14 | "lint:check": "eslint . --ext .ts,.tsx", 15 | "lint:fmt": "eslint . --fix --ext .ts,.tsx", 16 | "format": "prettier '{pages,server,src}/**/*.{js,ts,tsx}' --write", 17 | "test": "jest", 18 | "gen": "hygen generate" 19 | }, 20 | "lint-staged": { 21 | "*.{ts,tsx}": [ 22 | "eslint . --ext .ts,.tsx --fix" 23 | ] 24 | }, 25 | "keywords": [ 26 | "next.js", 27 | "typescript" 28 | ], 29 | "author": "Kotaro Chiba", 30 | "license": "MIT", 31 | "dependencies": { 32 | "@koa/router": "^10.0.0", 33 | "babel-plugin-module-resolver": "^4.0.0", 34 | "bunyan": "^1.8.14", 35 | "cross-env": "^7.0.2", 36 | "jwt-decode": "^3.0.0", 37 | "koa": "^2.13.0", 38 | "koa-helmet": "^6.0.0", 39 | "module-alias": "^2.2.2", 40 | "next": "^11.1.1", 41 | "next-redux-wrapper": "^6.0.2", 42 | "react": "^17.0.0", 43 | "react-dom": "^17.0.0", 44 | "react-redux": "^7.2.1", 45 | "redux": "^4.0.5", 46 | "redux-logger": "^3.0.6", 47 | "redux-saga": "^1.1.3", 48 | "universal-cookie": "^4.0.4" 49 | }, 50 | "devDependencies": { 51 | "@testing-library/react": "^11.1.0", 52 | "@testing-library/react-hooks": "^5.1.1", 53 | "@types/bunyan": "^1.8.6", 54 | "@types/enzyme": "^3.10.7", 55 | "@types/jest": "^26.0.15", 56 | "@types/jwt-decode": "^3.1.0", 57 | "@types/koa": "^2.11.6", 58 | "@types/koa__router": "^8.0.3", 59 | "@types/koa-helmet": "^6.0.1", 60 | "@types/koa-router": "^7.4.1", 61 | "@types/react": "^17.0.3", 62 | "@types/react-dom": "^17.0.3", 63 | "@types/react-redux": "^7.1.9", 64 | "@types/redux-logger": "^3.0.8", 65 | "@types/universal-cookie": "^3.0.0", 66 | "@typescript-eslint/eslint-plugin": "^4.5.0", 67 | "@typescript-eslint/parser": "^4.5.0", 68 | "@typescript-eslint/typescript-estree": "^4.5.0", 69 | "babel-jest": "^26.6.0", 70 | "enzyme": "^3.11.0", 71 | "eslint": "^7.11.0", 72 | "eslint-config-prettier": "^8.2.0", 73 | "eslint-plugin-react": "^7.21.5", 74 | "husky": "^4.3.0", 75 | "hygen": "^6.0.4", 76 | "jest": "^26.6.0", 77 | "jest-fetch-mock": "^3.0.3", 78 | "lint-staged": "^10.4.2", 79 | "nodemon": "^2.0.6", 80 | "prettier": "^2.1.2", 81 | "redux-saga-test-plan": "^4.0.1", 82 | "snapshot-diff": "^0.8.1", 83 | "ts-jest": "^26.4.1", 84 | "ts-node": "^9.0.0", 85 | "typescript": "^4.0.3" 86 | }, 87 | "_moduleAliases": { 88 | "~": "src", 89 | "~pages": "pages" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import App, { AppContext } from 'next/app'; 3 | 4 | import { Dispatch } from 'redux'; 5 | import { wrapper, StoreWithSaga } from '~/store'; 6 | 7 | import { counterActions } from '~/actions'; 8 | import { NextPageContext } from 'next'; 9 | 10 | interface NextPageContextWithStore extends NextPageContext { 11 | store: StoreWithSaga; 12 | } 13 | 14 | class CustomApp extends App { 15 | static async getInitialProps({ Component, ctx }: AppContext) { 16 | let pageProps = {}; 17 | 18 | if (Component.getInitialProps) { 19 | pageProps = await Component.getInitialProps(ctx); 20 | } 21 | 22 | /** 23 | * @see https://github.com/uruha/next-with-ts/blob/af76ea71b8aeb5d3d970834e57f065a382cf71d6/src/store/index.ts#L39 24 | * Can't extended AppContext to include CustomProps Type, 25 | * tentatively forcibly expand ctx.store via `as`. 26 | */ 27 | await (ctx as NextPageContextWithStore).store.execSagaTask( 28 | !!ctx.req, 29 | (dispatch: Dispatch) => { 30 | dispatch(counterActions.increment(1)); 31 | } 32 | ); 33 | 34 | return { pageProps }; 35 | } 36 | 37 | render() { 38 | const { Component, pageProps } = this.props; 39 | 40 | return ; 41 | } 42 | } 43 | 44 | export default wrapper.withRedux(CustomApp); 45 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Document, { 3 | Html, 4 | Head, 5 | Main, 6 | NextScript, 7 | DocumentContext 8 | } from 'next/document'; 9 | 10 | import crypto from 'crypto'; 11 | 12 | const cspHashOf = (text: string) => { 13 | const hash = crypto.createHash('sha256'); 14 | hash.update(text); 15 | return `'sha256-${hash.digest('base64')}'`; 16 | }; 17 | 18 | class CustomDocument extends Document { 19 | static async getInitialProps(ctx: DocumentContext) { 20 | const initialProps = await Document.getInitialProps(ctx); 21 | return { ...initialProps }; 22 | } 23 | 24 | render() { 25 | let csp = `default-src 'self'; script-src 'self' ${cspHashOf( 26 | NextScript.getInlineScriptSource(this.props) 27 | )}`; 28 | if (process.env.NODE_ENV !== 'production') { 29 | csp = `style-src 'self' 'unsafe-inline'; font-src 'self' data:; default-src 'self'; script-src 'unsafe-eval' 'self' ${cspHashOf( 30 | NextScript.getInlineScriptSource(this.props) 31 | )}`; 32 | } 33 | 34 | return ( 35 | 36 | 37 | 42 | 43 | 44 | 45 |
46 | 47 | 48 | 49 | ); 50 | } 51 | } 52 | 53 | export default CustomDocument; 54 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NextPageContext } from 'next'; 3 | 4 | interface ErrorProps { 5 | statusCode?: number; 6 | } 7 | 8 | class CustomError extends React.Component { 9 | static async getInitialProps({ res, err }: NextPageContext) { 10 | const statusCode = res ? res.statusCode : err ? err.stack : null; 11 | 12 | return { statusCode }; 13 | } 14 | 15 | render() { 16 | const { statusCode } = this.props; 17 | return ( 18 |
19 |

20 | {statusCode 21 | ? `An error ${statusCode} occurred on server` 22 | : 'An error occurred on client'} 23 |

24 |
25 | ); 26 | } 27 | } 28 | 29 | export default CustomError; 30 | -------------------------------------------------------------------------------- /pages/api/account.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Account } from '~/modelTypes'; 3 | 4 | import Cookies from 'universal-cookie'; 5 | import Auth from '~/service/auth'; 6 | 7 | const pseudoAccount: Account = { 8 | email: 'kotaro@example.com', 9 | nickname: 'Jankovic' 10 | }; 11 | 12 | export default (req: NextApiRequest, res: NextApiResponse) => { 13 | const cookies = new Cookies(req.headers.cookie); 14 | const _token = cookies.get('_token'); 15 | 16 | if (!_token) { 17 | res.setHeader('Content-Type', 'application/json'); 18 | res.statusCode = 200; 19 | res.end(JSON.stringify({ email: '', nickname: '' })); 20 | return; 21 | } 22 | 23 | const accessAuth = new Auth(_token); 24 | 25 | if (!accessAuth.isAuthenticated) { 26 | res.status(401).send('Expire is over.'); 27 | return; 28 | } 29 | 30 | /** 31 | * @TODO 32 | * GET api `/accounts/me` 33 | */ 34 | res.setHeader('Content-Type', 'application/json'); 35 | res.setHeader('Authorization', `${accessAuth.authorizationString}`); 36 | res.statusCode = 200; 37 | res.end(JSON.stringify(pseudoAccount)); 38 | }; 39 | -------------------------------------------------------------------------------- /pages/api/signin.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Token } from '~/modelTypes'; 3 | 4 | import Cookies from 'universal-cookie'; 5 | import Auth from '~/service/auth'; 6 | 7 | const pseudoToken: Token = { 8 | access_token: 9 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNoaWJhIEtvdGFybyIsImlhdCI6MTU3NTQ0MzI4MiwiZXhwIjoxNjA5NDI2Nzk5fQ.DHZMvPjPCWMMyqo4v5oRM6ho3Miv4XwAxOr7gJBLyvc', 10 | refresh_token: 'GEbRxBN_pseudoRefreshToken_edjnXbL', 11 | type: 'Bearer' 12 | }; 13 | 14 | export default (req: NextApiRequest, res: NextApiResponse) => { 15 | const cookies = new Cookies(req.headers.cookie); 16 | console.log(cookies); // get HttpOnly directive cookie 17 | console.log(req.body); // get request body 18 | 19 | if (req.method === 'POST') { 20 | /** 21 | * @TODO 22 | * POST api `/auth/singin` 23 | */ 24 | const accessAuth = new Auth(pseudoToken.access_token); 25 | res.setHeader('Content-Type', 'application/json'); 26 | res.setHeader( 27 | 'Set-Cookie', 28 | `_token=${pseudoToken.access_token}; Path=/; Max-Age=${accessAuth.maxAgeAt}; HttpOnly` 29 | ); 30 | res.statusCode = 201; 31 | res.end(JSON.stringify({ token: pseudoToken })); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /pages/api/signout.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | // import Cookies from 'universal-cookie'; 4 | 5 | export default (req: NextApiRequest, res: NextApiResponse) => { 6 | // const cookies = new Cookies(req.headers.cookie); 7 | 8 | if (req.method === 'POST') { 9 | /** 10 | * @TODO 11 | * POST api `/auth/signout` 12 | */ 13 | res.setHeader('Content-Type', 'application/json'); 14 | res.setHeader('Set-Cookie', '_token=; Path=/; Max-Age=0; HttpOnly'); 15 | res.statusCode = 204; 16 | res.end(); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /pages/api/ua.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { judgeIsMobile } from '~/lib/ua'; 3 | 4 | export default (req: NextApiRequest, res: NextApiResponse) => { 5 | const legacyUA = req.headers['user-agent']; 6 | const CHUAM = req.headers['sec-ch-ua-mobile']; 7 | 8 | const isMobile = judgeIsMobile(legacyUA, CHUAM); 9 | 10 | res.setHeader('Content-Type', 'application/json'); 11 | res.statusCode = 200; 12 | res.end(JSON.stringify({ isMobile })); 13 | }; 14 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Layout from '~/components/Layout'; 3 | 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { counterActions } from '~/actions'; 6 | import { RootState } from '~/reducers'; 7 | 8 | import { CountState } from '~/stateTypes'; 9 | 10 | const Index: React.FC = () => { 11 | const counter = useSelector(state => state.counter); 12 | const dispatch = useDispatch(); 13 | 14 | return ( 15 | 16 |

Hello Next.js with Koa.js and Typescript

17 |
count: {counter && counter.count}
18 |
19 | 22 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default Index; 31 | -------------------------------------------------------------------------------- /pages/signin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FormEvent } from 'react'; 3 | import useEmail from '~/hooks/signin/useEmail'; 4 | import usePassword from '~/hooks/signin/usePassword'; 5 | import Router from 'next/router'; 6 | import http from '~/lib/http'; 7 | 8 | import { useDispatch } from 'react-redux'; 9 | import { accountActions } from '~/actions'; 10 | 11 | import { SigninRequest } from '~/modelTypes'; 12 | 13 | const Signin: React.FC = () => { 14 | const email = useEmail(''); 15 | const password = usePassword(''); 16 | const dispatch = useDispatch(); 17 | 18 | const handleSubmit = async (e: FormEvent) => { 19 | e.preventDefault(); 20 | const signinRequest: SigninRequest = { 21 | email: email.entertedValue, 22 | password: password.entertedValue 23 | }; 24 | 25 | try { 26 | const res = await http.post('/api/signin', { 27 | body: JSON.stringify(signinRequest) 28 | }); 29 | 30 | if (res.status === 201) { 31 | dispatch(accountActions.getAccount()); 32 | Router.push('/'); 33 | } 34 | } catch (error) { 35 | console.log(error); 36 | } 37 | }; 38 | 39 | return ( 40 |
{ 42 | handleSubmit(e); 43 | email.setInputValue(''); 44 | password.setInputValue(''); 45 | }} 46 | > 47 |

Signin

48 |
49 | 50 | 56 |
57 |
58 | 59 | 65 |
66 | 67 |
68 | ); 69 | }; 70 | 71 | export default Signin; 72 | -------------------------------------------------------------------------------- /pages/todo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, KeyboardEvent, useState } from 'react'; 2 | import TodoLists, { Task } from '~/components/TodoLists'; 3 | import { useTodo, useTodoList } from '~/hooks/todo'; 4 | 5 | const initialTasksState: Task[] = []; 6 | 7 | const Todo: React.FC = () => { 8 | // 入力フォームの一時保管用 9 | const todo = useTodo(''); 10 | 11 | // タスク一覧 12 | const tasks = useTodoList(initialTasksState); 13 | 14 | // 編集モードかどうかのフラグ 15 | const [isEditable, setEditable] = useState(false); 16 | 17 | const handleSubmit = (e: FormEvent) => { 18 | e.preventDefault(); 19 | }; 20 | 21 | const handleOnKeyDown = (e: KeyboardEvent) => { 22 | // INFO: ENTER Key 23 | if (e.keyCode !== 13) return; 24 | 25 | tasks.add(todo.value); 26 | todo.reset(); 27 | }; 28 | 29 | const hasTaskList = tasks.list.length > 0; 30 | 31 | return ( 32 | <> 33 |
34 |
35 |

今日のやること

36 | {hasTaskList && ( 37 | 43 | )} 44 |
45 | {hasTaskList ? ( 46 | 52 | ) : ( 53 |

タスクはありません!

54 | )} 55 | 56 |
57 | 65 | 75 |
76 |
77 | 78 | 130 | 131 | ); 132 | }; 133 | 134 | export default Todo; 135 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import Koa, { Context, DefaultState } from 'koa'; 2 | import Router from '@koa/router'; 3 | import KoaHelmet from 'koa-helmet'; 4 | import next from 'next'; 5 | 6 | import Logger from 'bunyan'; 7 | 8 | const logOptions: Logger.LoggerOptions = { 9 | name: 'app', 10 | streams: [ 11 | { 12 | level: 'info', 13 | stream: process.stdout 14 | }, 15 | { 16 | level: 'error', 17 | stream: process.stderr 18 | } 19 | ], 20 | serializers: { 21 | req: req => ({ 22 | method: req.method, 23 | url: req.url, 24 | referer: req.headers.referer 25 | }), 26 | err: Logger.stdSerializers.err 27 | } 28 | }; 29 | 30 | interface CtxWithLogger extends Context { 31 | logger: Logger; 32 | } 33 | 34 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; 35 | const dev = process.env.NODE_ENV !== 'production'; 36 | const app = next({ dev }); 37 | const handle = app.getRequestHandler(); 38 | 39 | const logger = Logger.createLogger(logOptions); 40 | 41 | app.prepare() 42 | .then(() => { 43 | const server = new Koa(); 44 | const router = new Router(); 45 | 46 | server.use(async (ctx, next) => { 47 | ctx.logger = logger; 48 | await next(); 49 | }); 50 | 51 | router.get('/health', async ctx => { 52 | await ctx.logger.info({ req: ctx.req }, 'HEALTH'); 53 | ctx.status = 200; 54 | ctx.type = 'application/json'; 55 | ctx.body = JSON.stringify({ uptime: process.uptime() }); 56 | }); 57 | 58 | router.get(/.*/, async ctx => { 59 | await ctx.logger.info({ req: ctx.req }, 'REQUEST'); 60 | await handle(ctx.req, ctx.res); 61 | ctx.respond = false; 62 | }); 63 | 64 | router.post(/.*/, async ctx => { 65 | await ctx.logger.info({ req: ctx.req }, 'REQUEST'); 66 | await handle(ctx.req, ctx.res); 67 | ctx.respond = false; 68 | }); 69 | 70 | server.use(async (ctx, next) => { 71 | ctx.res.statusCode = 200; 72 | await next(); 73 | }); 74 | 75 | /** 76 | * @MEMO 77 | * CSP (Content Security Policy) is false for Web Server, 78 | * so CSP is controlled by next.js `_document.tsx` 79 | */ 80 | server.use( 81 | KoaHelmet({ 82 | contentSecurityPolicy: false 83 | }) 84 | ); 85 | server.use(router.routes()); 86 | server.listen(port, () => { 87 | logger.info(`> Ready on localhost:${port}`); 88 | }); 89 | }) 90 | .catch((err: Error) => logger.error(err, 'Server prepared is faild.')); 91 | -------------------------------------------------------------------------------- /setupJestFetchMock.js: -------------------------------------------------------------------------------- 1 | require('jest-fetch-mock').enableMocks(); 2 | global.fetch.mockResponse(JSON.stringify({ data: 'ok' })); 3 | -------------------------------------------------------------------------------- /src/__mocks__/next/config.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | publicRuntimeConfig: {} 3 | }); 4 | -------------------------------------------------------------------------------- /src/actionTypes/account.ts: -------------------------------------------------------------------------------- 1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant'; 2 | import { Account } from '~/modelTypes'; 3 | 4 | interface GetAccountAction { 5 | type: typeof GET_ACCOUNT; 6 | } 7 | 8 | interface UpdateAccountAction { 9 | type: typeof UPDATE_ACCOUNT; 10 | payload: { data: Account }; 11 | } 12 | 13 | export type AccountActionTypes = GetAccountAction | UpdateAccountAction; 14 | -------------------------------------------------------------------------------- /src/actionTypes/counter.ts: -------------------------------------------------------------------------------- 1 | import { INCREMENT, DECREMENT, UPDATE_COUNT } from '~/constant'; 2 | import { HYDRATE } from 'next-redux-wrapper'; 3 | import { RootState } from '~/reducers'; 4 | 5 | interface IncrementAction { 6 | type: typeof INCREMENT; 7 | payload: number; 8 | } 9 | 10 | interface DecrementAction { 11 | type: typeof DECREMENT; 12 | payload: number; 13 | } 14 | 15 | interface UpdateCountAction { 16 | type: typeof UPDATE_COUNT; 17 | payload: number; 18 | } 19 | 20 | interface HydrateCountAction { 21 | type: typeof HYDRATE; 22 | payload: RootState; 23 | } 24 | 25 | export type CounterActionTypes = 26 | | IncrementAction 27 | | DecrementAction 28 | | UpdateCountAction 29 | | HydrateCountAction; 30 | -------------------------------------------------------------------------------- /src/actionTypes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account'; 2 | export * from './counter'; 3 | -------------------------------------------------------------------------------- /src/actions/account/__test__/account.test.ts: -------------------------------------------------------------------------------- 1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant'; 2 | import { Account } from '~/modelTypes'; 3 | import { accountActions } from '~/actions'; 4 | 5 | describe('Account actions', () => { 6 | it('should create get account action', () => { 7 | const expectedAction = { 8 | type: GET_ACCOUNT 9 | }; 10 | 11 | expect(accountActions.getAccount()).toEqual(expectedAction); 12 | }); 13 | 14 | it('should create update accont action', () => { 15 | const expectedPayload: Account = { 16 | nickname: 'Test', 17 | email: 'accout@test.com' 18 | }; 19 | const expectedAction = { 20 | type: UPDATE_ACCOUNT, 21 | payload: { 22 | data: { 23 | nickname: expectedPayload.nickname, 24 | email: expectedPayload.email 25 | } 26 | } 27 | }; 28 | 29 | expect(accountActions.updateAccount(expectedPayload)).toEqual( 30 | expectedAction 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/actions/account/index.ts: -------------------------------------------------------------------------------- 1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant'; 2 | import { AccountActionTypes } from '~/actionTypes'; 3 | import { Account } from '~/modelTypes'; 4 | 5 | export const getAccount = (): AccountActionTypes => ({ 6 | type: GET_ACCOUNT 7 | }); 8 | 9 | export const updateAccount = (account: Account): AccountActionTypes => ({ 10 | type: UPDATE_ACCOUNT, 11 | payload: { data: account } 12 | }); 13 | -------------------------------------------------------------------------------- /src/actions/counter/__test__/counter.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { INCREMENT, DECREMENT } from '~/constant'; 3 | import { counterActions } from '~/actions'; 4 | 5 | describe('Counter actions', () => { 6 | it('should create increment action', () => { 7 | const wantToIncrementNumber = 1; 8 | const expectedAction = { 9 | type: INCREMENT, 10 | payload: wantToIncrementNumber 11 | }; 12 | 13 | expect(counterActions.increment(1)).toEqual(expectedAction); 14 | }); 15 | 16 | it('should create decrement action', () => { 17 | const wantToDecrementNumber = 3; 18 | const expectedAction = { 19 | type: DECREMENT, 20 | payload: wantToDecrementNumber 21 | }; 22 | 23 | expect(counterActions.decrement(3)).toEqual(expectedAction); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/actions/counter/index.ts: -------------------------------------------------------------------------------- 1 | import { INCREMENT, DECREMENT, UPDATE_COUNT } from '~/constant'; 2 | import { CounterActionTypes } from '~/actionTypes'; 3 | 4 | export const increment = (number: number): CounterActionTypes => ({ 5 | type: INCREMENT, 6 | payload: number 7 | }); 8 | 9 | export const decrement = (number: number): CounterActionTypes => ({ 10 | type: DECREMENT, 11 | payload: number 12 | }); 13 | 14 | export const updateCount = (number: number): CounterActionTypes => ({ 15 | type: UPDATE_COUNT, 16 | payload: number 17 | }); 18 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as accountActions from './account'; 2 | import * as counterActions from './counter'; 3 | 4 | // eslint-disable-next-line 5 | export { accountActions, counterActions }; 6 | -------------------------------------------------------------------------------- /src/components/Layout/__test__/Layout.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as React from 'react'; 3 | import { cleanup } from '@testing-library/react'; 4 | 5 | import Layout from '~/components/Layout'; 6 | 7 | import { renderWithProvider, withMockedRouter } from '~/lib/testing'; 8 | 9 | describe('Layout', () => { 10 | afterEach(cleanup); 11 | 12 | it('render navigation component', () => { 13 | const { container } = renderWithProvider( 14 | withMockedRouter( 15 | { 16 | pathname: '/' 17 | }, 18 | 19 | ) 20 | ); 21 | const navComponent = container.getElementsByTagName('nav'); 22 | expect(navComponent.length > 0).toStrictEqual(true); 23 | }); 24 | 25 | it('render children component', () => { 26 | const { container } = renderWithProvider( 27 | withMockedRouter( 28 | { 29 | pathname: '/' 30 | }, 31 | 32 |
33 | 34 | ) 35 | ); 36 | const childComponent = container.getElementsByClassName('test'); 37 | expect(childComponent.length > 0).toStrictEqual(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import Nav from '~/components/Nav'; 5 | 6 | import { useEffect } from 'react'; 7 | import { useRouter } from 'next/router'; 8 | import { useSelector, useDispatch } from 'react-redux'; 9 | import { RootState } from '~/reducers'; 10 | import { AccountState } from '~/stateTypes'; 11 | import { accountActions } from '~/actions'; 12 | 13 | interface LayoutProps { 14 | title?: string; 15 | } 16 | 17 | const Layout: React.FC = ({ 18 | children, 19 | title = 'Default title' 20 | }) => { 21 | const account = useSelector( 22 | state => state.account 23 | ); 24 | const hasAccountData = Boolean(account.data.email && account.data.nickname); 25 | 26 | const { pathname } = useRouter(); 27 | const dispatch = useDispatch(); 28 | useEffect(() => { 29 | /** User Agent detection */ 30 | (async () => { 31 | fetch('/api/ua') 32 | .then(r => r.json()) 33 | .then(ua => console.log(ua)); 34 | })(); 35 | 36 | if (pathname !== '/signin') { 37 | if (!hasAccountData) { 38 | dispatch(accountActions.getAccount()); 39 | } 40 | } 41 | }, []); 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 51 | {title} 52 | 53 |
54 |
56 |
{children}
57 |
58 | 59 | Copyright © since 1987 Kotaro Chiba I`m from Yokohama. 60 | 61 |
62 | 63 | ); 64 | }; 65 | 66 | export default Layout; 67 | -------------------------------------------------------------------------------- /src/components/Nav/__test__/Nav.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as React from 'react'; 3 | import Nav from '~/components/Nav'; 4 | import { render } from '@testing-library/react'; 5 | 6 | describe('Navigation', () => { 7 | it('renders navigation list items collection', () => { 8 | const account = { 9 | data: { 10 | email: 'test@example.com', 11 | nickname: 'Geronimo' 12 | } 13 | }; 14 | const hasAccountData = true; 15 | const { container } = render( 16 |