├── src ├── server │ ├── routes │ │ ├── todoApp │ │ │ ├── types.ts │ │ │ └── index.ts │ │ ├── SSR │ │ │ ├── types.ts │ │ │ └── index.tsx │ │ └── index.ts │ ├── index.ts │ └── server.ts ├── common │ ├── Root │ │ ├── screens │ │ │ ├── TodoApp │ │ │ │ ├── components │ │ │ │ │ ├── ActiveItemsCount │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Filters │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── TodoInput │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── TodoItem │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── components │ │ │ │ │ │ │ ├── RemoveButton.tsx │ │ │ │ │ │ │ └── CompletedIcon.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── TodoList │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ ├── todos.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── apiService.ts │ │ │ │ ├── actionCreators.ts │ │ │ │ ├── sagas.ts │ │ │ │ ├── reducer.ts │ │ │ │ ├── index.tsx │ │ │ │ └── types.ts │ │ │ └── Home │ │ │ │ ├── components │ │ │ │ ├── cat.png │ │ │ │ └── CatImage.tsx │ │ │ │ └── index.tsx │ │ ├── components │ │ │ └── MainMenu │ │ │ │ ├── types.ts │ │ │ │ └── index.tsx │ │ ├── mainMenuItems.ts │ │ └── index.tsx │ ├── assets │ │ └── fonts │ │ │ ├── Inter-UI-Bold.woff │ │ │ ├── Inter-UI-Bold.woff2 │ │ │ ├── Inter-UI-Italic.woff │ │ │ ├── Inter-UI-Italic.woff2 │ │ │ ├── Inter-UI-Regular.woff │ │ │ └── Inter-UI-Regular.woff2 │ ├── shared │ │ └── components │ │ │ ├── RedirectWithStatus │ │ │ ├── interfaces.ts │ │ │ └── index.tsx │ │ │ └── NotFound │ │ │ └── index.tsx │ ├── theme.ts │ ├── types.ts │ ├── configureStore.ts │ └── globalStyles.ts └── client.tsx ├── config ├── production.json ├── development.json ├── default.json └── webpack │ ├── common.js │ ├── server.js │ └── client.js ├── .gitattributes ├── scripts ├── clean.js ├── build.js └── start.js ├── tsconfig.json ├── .babelrc ├── types.d.ts ├── .gitignore ├── package.json └── README.md /src/server/routes/todoApp/types.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "serverPort": 8080 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | scripts/* linguist-vendored 2 | config/* linguist-vendored -------------------------------------------------------------------------------- /config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "serverPort": 3001, 3 | "wdsPort": 3000 4 | } 5 | -------------------------------------------------------------------------------- /src/server/routes/SSR/types.ts: -------------------------------------------------------------------------------- 1 | export interface TStaticContext { 2 | url?: string; 3 | status?: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/server/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SSR } from "./SSR"; 2 | export { default as todoApp } from "./todoApp"; 3 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildPaths": { 3 | "client": "public", 4 | "server": "build" 5 | }, 6 | "publicPath": "/assets/" 7 | } 8 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/ActiveItemsCount/types.ts: -------------------------------------------------------------------------------- 1 | export interface TActiveItemsCountProps { 2 | itemsLeft: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Bold.woff -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import "babel-polyfill"; 2 | import config from "config"; 3 | import app from "./server"; 4 | 5 | app.listen(config.get("serverPort")); 6 | -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Bold.woff2 -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Italic.woff -------------------------------------------------------------------------------- /src/common/Root/screens/Home/components/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/Root/screens/Home/components/cat.png -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Italic.woff2 -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /src/common/assets/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osenvosem/typescript-full-stack-boilerplate/HEAD/src/common/assets/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /src/common/shared/components/RedirectWithStatus/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IRedirectWithStatusProps { 2 | from: string; 3 | to: string; 4 | status: 301 | 302; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/Filters/types.ts: -------------------------------------------------------------------------------- 1 | import { TFilterChangeHandler, FilterTypes } from "../../types"; 2 | 3 | export interface TFilterProps { 4 | onFilterChange: TFilterChangeHandler; 5 | value: FilterTypes; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoInput/types.ts: -------------------------------------------------------------------------------- 1 | import { TAddTodoHandler, TInputChangeHandler } from "../../types"; 2 | 3 | export interface TTodoAddProps { 4 | onButtonClick: TAddTodoHandler; 5 | onInputChange: TInputChangeHandler; 6 | inputValue: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/todos.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { text: "Learn Typescript.", completed: true, id: 1 }, 3 | { text: "Learn React.", completed: true, id: 2 }, 4 | { text: "Sleep a little.", completed: false, id: 3 }, 5 | { text: "Learn Redux.", completed: false, id: 4 } 6 | ]; 7 | -------------------------------------------------------------------------------- /src/common/Root/components/MainMenu/types.ts: -------------------------------------------------------------------------------- 1 | import { TTheme } from "../../../types"; 2 | 3 | export interface TMenuItem { 4 | title: string; 5 | to: string; 6 | exact?: boolean; 7 | } 8 | 9 | export interface TMainMenuProps { 10 | items: TMenuItem[]; 11 | theme: TTheme; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/theme.ts: -------------------------------------------------------------------------------- 1 | import { TTheme } from "./types"; 2 | 3 | const theme: TTheme = { 4 | primary: "#76C4D4", 5 | secondary: "#EB2F49", 6 | tertiary: "#F6F792", 7 | dark: "#333845", 8 | grey50: "#FAFAFA", 9 | grey200: "#EEEEEE", 10 | grey300: "#E0E0E0" 11 | }; 12 | 13 | export default theme; 14 | -------------------------------------------------------------------------------- /src/common/Root/screens/Home/components/CatImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import image from "./cat.png"; 5 | 6 | const Image = styled.img``; 7 | 8 | const CatImage = () => { 9 | return ; 10 | }; 11 | 12 | export default CatImage; 13 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | const rimraf = require("rimraf"); 2 | const config = require("config"); 3 | 4 | const paths = config.get("buildPaths"); 5 | 6 | rimraf(`${paths.client}/*`, err => { 7 | if (err) console.error(err); 8 | }); 9 | 10 | rimraf(`${paths.server}/*`, err => { 11 | if (err) console.error(err); 12 | }); 13 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoItem/types.ts: -------------------------------------------------------------------------------- 1 | import { TToggleTodoHandler, TRemoveTodoHandler } from "../../types"; 2 | 3 | export interface TTodoItemProps { 4 | completed: boolean; 5 | id: number; 6 | children: string; 7 | onRemoveTodo: TRemoveTodoHandler; 8 | onToggleTodo: TToggleTodoHandler; 9 | } 10 | -------------------------------------------------------------------------------- /src/common/Root/mainMenuItems.ts: -------------------------------------------------------------------------------- 1 | import { TMenuItem } from "./components/MainMenu/types"; 2 | 3 | const mainMenuItems: TMenuItem[] = [ 4 | { 5 | title: "Home", 6 | to: "/", 7 | exact: true 8 | }, 9 | { 10 | title: "Todo App", 11 | to: "/todoapp" 12 | } 13 | ]; 14 | 15 | export default mainMenuItems; 16 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { SSR, todoApp } from "./routes/"; 3 | 4 | const app = express(); 5 | 6 | if (process.env.NODE_ENV === "production") { 7 | app.use("/assets/", express.static("public")); 8 | } 9 | 10 | app.use(express.json()); 11 | 12 | app.use("/api/v1/todoapp", todoApp); 13 | app.use(SSR); 14 | 15 | export default app; 16 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoList/types.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent, MouseEvent } from "react"; 2 | import { 3 | TTodo, 4 | TAddTodoHandler, 5 | TRemoveTodoHandler, 6 | TToggleTodoHandler 7 | } from "../../types"; 8 | 9 | export interface TTodoListProps { 10 | todos: TTodo[]; 11 | onAddTodo: TAddTodoHandler; 12 | onRemoveTodo: TRemoveTodoHandler; 13 | onToggleTodo: TToggleTodoHandler; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "noEmit": true, 8 | "sourceMap": true, 9 | "lib": ["es2017", "dom"], 10 | "allowJs": true, 11 | "jsx": "preserve", 12 | "strict": true, 13 | "experimentalDecorators": true 14 | }, 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "browsers": ["last 2 versions"] 9 | } 10 | } 11 | ], 12 | ["@babel/preset-stage-2", { "decoratorsLegacy": true }], 13 | "@babel/preset-react" 14 | ], 15 | "plugins": ["react-loadable/babel"], 16 | "env": { 17 | "development": { 18 | "presets": ["@babel/preset-env"], 19 | "plugins": ["react-hot-loader/babel"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/ActiveItemsCount/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { TActiveItemsCountProps } from "./types"; 5 | 6 | const Span = styled.span` 7 | color: rgba(0, 0, 0, 0.87); 8 | `; 9 | 10 | const ActiveItemsCount: SFC = ({ itemsLeft }) => { 11 | return ( 12 | 13 | {itemsLeft} {itemsLeft > 1 ? "items" : "item"} left 14 | 15 | ); 16 | }; 17 | 18 | export default ActiveItemsCount; 19 | -------------------------------------------------------------------------------- /src/common/shared/components/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC, ReactNode } from "react"; 2 | import { Route } from "react-router-dom"; 3 | 4 | interface INotFoundProps { 5 | children?: ReactNode; 6 | } 7 | 8 | const NotFound: SFC = ({ children }) => { 9 | return ( 10 | { 12 | if (staticContext) staticContext.code = 404; 13 | return ( 14 |
15 |

Page not found.

16 | {children} 17 |
18 | ); 19 | }} 20 | /> 21 | ); 22 | }; 23 | 24 | export default NotFound; 25 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "redux"; 2 | import { Task, END } from "redux-saga"; 3 | import { TTodoAppState } from "./Root/screens/TodoApp/types"; 4 | 5 | export interface TRootState { 6 | todoApp: TTodoAppState; 7 | } 8 | 9 | export interface CustomStore extends Store { 10 | runSaga: (saga: () => Iterator) => Task; 11 | close: () => END; 12 | } 13 | 14 | export interface TTheme { 15 | readonly primary: string; 16 | readonly secondary: string; 17 | readonly tertiary: string; 18 | readonly dark: string; 19 | readonly grey50: string; 20 | readonly grey200: string; 21 | readonly grey300: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/utils.ts: -------------------------------------------------------------------------------- 1 | import { TTodo, FilterTypes } from "./types"; 2 | 3 | export const filterTodos = (todos: TTodo[], filter: FilterTypes): TTodo[] => { 4 | switch (filter) { 5 | case FilterTypes.SHOW_ALL: 6 | return todos; 7 | case FilterTypes.SHOW_COMPLETED: 8 | return todos.filter(todo => todo.completed); 9 | case FilterTypes.SHOW_INCOMPLETE: 10 | return todos.filter(todo => !todo.completed); 11 | default: 12 | return todos; 13 | } 14 | }; 15 | 16 | export const generateId = (todos: TTodo[]) => { 17 | return todos.length ? Math.max(...todos.map(todo => todo.id)) + 1 : 0; 18 | }; 19 | -------------------------------------------------------------------------------- /src/common/shared/components/RedirectWithStatus/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | import { IRedirectWithStatusProps } from "./interfaces"; 4 | 5 | const RedirectWithStatus = (props: IRedirectWithStatusProps) => { 6 | const { from, to, status } = props; 7 | console.log("From RedirectWithStatus!!!"); 8 | 9 | return ( 10 | { 12 | if (staticContext) { 13 | staticContext.status = status; 14 | } 15 | return ; 16 | }} 17 | /> 18 | ); 19 | }; 20 | 21 | export default RedirectWithStatus; 22 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "json-loader!*"; 2 | declare module "*.css"; 3 | declare module "*.json"; 4 | 5 | // shared folder 6 | declare module "components/*"; 7 | declare module "services/*"; 8 | declare module "utils/*"; 9 | 10 | declare const CLIENT_ASSETS: string; 11 | declare module "react-loadable/webpack"; 12 | 13 | interface Window { 14 | __INITIAL_STATE__: any; 15 | __REDUX_DEVTOOLS_EXTENSION__: () => any; 16 | } 17 | 18 | declare namespace Express { 19 | export interface Request { 20 | todoId: number; 21 | } 22 | } 23 | 24 | declare module "*.woff"; 25 | declare module "*.woff2"; 26 | declare module "*.jpg"; 27 | declare module "*.png"; 28 | declare module "*.svg"; 29 | declare module "*.webp"; 30 | -------------------------------------------------------------------------------- /src/common/Root/screens/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import CatImage from "./components/CatImage"; 5 | 6 | const Section = styled.section` 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | `; 11 | 12 | const H1 = styled.h1` 13 | color: rgba(0, 0, 0, 0.87); 14 | font-size: 34px; 15 | `; 16 | const H2 = styled.h2` 17 | color: rgba(0, 0, 0, 0.87); 18 | font-size: 24px; 19 | `; 20 | 21 | class Home extends Component { 22 | render() { 23 | return ( 24 |
25 |

Welcome to the Homepage

26 | 27 |

28 | Don't forget to check out the Demo app 29 |

30 |
31 | ); 32 | } 33 | } 34 | 35 | export default Home; 36 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/apiService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import todos from "./todos"; 4 | import { TTodo } from "./types"; 5 | 6 | const todoApi = axios.create({ baseURL: "/api/v1/todoapp" }); 7 | 8 | const fakeDbRequest = () => { 9 | return new Promise(resolve => { 10 | setTimeout(() => { 11 | resolve(todos); 12 | }, 100); 13 | }); 14 | }; 15 | 16 | export const fetchTodos = () => { 17 | if (typeof window === "object") { 18 | return todoApi.get("/").then(response => response.data); 19 | } else { 20 | return fakeDbRequest(); 21 | } 22 | }; 23 | 24 | export const postTodo = (todo: TTodo) => { 25 | return todoApi.post("/", todo).then(response => response.data); 26 | }; 27 | 28 | export const removeTodo = (id: number) => { 29 | return todoApi.delete(`/${id}`); 30 | }; 31 | 32 | export const changeCompleted = (id: number) => { 33 | return todoApi.patch(`/${id}`); 34 | }; 35 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { TTodoListProps } from "./types"; 5 | import TodoItem from "../TodoItem"; 6 | 7 | const Section = styled.section` 8 | width: 600px; 9 | margin-top: 16px; 10 | border-radius: 12px; 11 | background: white; 12 | box-shadow: 0 6px 16px #efefef; 13 | padding: 0 16px; 14 | `; 15 | 16 | const TodoList: SFC = ({ 17 | todos, 18 | onRemoveTodo, 19 | onToggleTodo 20 | }) => { 21 | return ( 22 |
23 | {todos.map(todo => ( 24 | 31 | {todo.text} 32 | 33 | ))} 34 |
35 | ); 36 | }; 37 | 38 | export default TodoList; 39 | -------------------------------------------------------------------------------- /config/webpack/common.js: -------------------------------------------------------------------------------- 1 | const globalConfig = require("config"); 2 | const publicPath = globalConfig.get("publicPath"); 3 | 4 | module.exports = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.tsx?$/, 9 | use: "babel-loader" 10 | }, 11 | { 12 | test: /.(woff2?|ttf|eot|svg)/, 13 | use: { 14 | loader: "file-loader", 15 | options: { 16 | name: "[name].[ext]", 17 | publicPath: publicPath + "fonts/", 18 | outputPath: "fonts/" 19 | } 20 | } 21 | }, 22 | { 23 | test: /.(jpg|png)$/, 24 | use: { 25 | loader: "file-loader", 26 | options: { 27 | publicPath: publicPath + "images/", 28 | outputPath: "images/" 29 | } 30 | } 31 | } 32 | ] 33 | }, 34 | resolve: { 35 | extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], 36 | modules: ["node_modules", "shared"] 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/client.tsx: -------------------------------------------------------------------------------- 1 | import "babel-polyfill"; 2 | import React from "react"; 3 | import { render, hydrate } from "react-dom"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import { Provider } from "react-redux"; 6 | 7 | import Main from "./common/Root/"; 8 | import configureStore from "./common/configureStore"; 9 | import todoAppSaga from "./common/Root/screens/TodoApp/sagas"; 10 | 11 | const isDev = process.env.NODE_ENV; 12 | 13 | const store = configureStore(window.__INITIAL_STATE__); 14 | // @ts-ignore 15 | store.runSaga(todoAppSaga); 16 | 17 | const RootComponent = ( 18 | 19 | 20 |
21 | 22 | 23 | ); 24 | 25 | const rootElement = document.body.querySelector("#root"); 26 | 27 | switch (process.env.NODE_ENV) { 28 | case "development": 29 | render(RootComponent, rootElement); 30 | break; 31 | case "production": 32 | hydrate(RootComponent, rootElement); 33 | break; 34 | } 35 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/actionCreators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TAddTodoActionCreator, 3 | TRemoveTodoActionCreator, 4 | TToggleTodoActionCreator, 5 | ActionTypes, 6 | FilterTypes, 7 | TFilterChangeActionCreator, 8 | ApiRequestTypes, 9 | TFetchRequestedActionCreator 10 | } from "./types"; 11 | 12 | export const addTodo: TAddTodoActionCreator = (text, id) => { 13 | return { 14 | type: ActionTypes.ADD_TODO, 15 | text, 16 | id 17 | }; 18 | }; 19 | 20 | export const removeTodo: TRemoveTodoActionCreator = id => { 21 | return { 22 | type: ActionTypes.REMOVE_TODO, 23 | id 24 | }; 25 | }; 26 | 27 | export const toggleTodo: TToggleTodoActionCreator = id => { 28 | return { 29 | type: ActionTypes.TOGGLE_TODO, 30 | id 31 | }; 32 | }; 33 | 34 | export const changeFilter: TFilterChangeActionCreator = filter => { 35 | return { type: filter }; 36 | }; 37 | 38 | export const fetchTodos: TFetchRequestedActionCreator = () => { 39 | return { type: ApiRequestTypes.TODOS_FETCH_REQUESTED }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoItem/components/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC, MouseEvent } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { TRemoveTodoHandler } from "../../../types"; 5 | 6 | const Button = styled.button` 7 | background: none; 8 | border: none; 9 | fill: #e0e0e0; 10 | &:hover { 11 | fill: ${props => props.theme.secondary}; 12 | } 13 | `; 14 | 15 | const RemoveIcon: SFC<{ 16 | onClick: (e: MouseEvent) => void; 17 | }> = ({ onClick }) => { 18 | return ( 19 | 27 | ); 28 | }; 29 | 30 | export default RemoveIcon; 31 | -------------------------------------------------------------------------------- /src/common/Root/components/MainMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import styled, { withTheme } from "styled-components"; 4 | 5 | import { TMainMenuProps } from "./types"; 6 | 7 | const Nav = styled.nav` 8 | > a { 9 | color: white; 10 | margin-right: 10px; 11 | text-decoration: none; 12 | opacity: 0.7; 13 | font-weight: 400; 14 | padding: 0 0 4px 0; 15 | } 16 | `; 17 | 18 | const MainMenu: SFC = ({ items, theme }) => { 19 | return ( 20 | 37 | ); 38 | }; 39 | 40 | export default withTheme(MainMenu); 41 | -------------------------------------------------------------------------------- /config/webpack/server.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const nodeExternals = require("webpack-node-externals"); 3 | const merge = require("webpack-merge"); 4 | const commonWebpackConfig = require("./common"); 5 | const globalConfig = require("config"); 6 | 7 | const isDev = process.env.NODE_ENV === "development"; 8 | 9 | const buildPaths = globalConfig.get("buildPaths"); 10 | 11 | const localWebpackConfig = { 12 | mode: isDev ? "development" : "production", 13 | entry: { 14 | server: path.resolve("./src/server/index.ts") 15 | }, 16 | output: { 17 | path: path.resolve(buildPaths.server), 18 | filename: "[name].bundle.js" 19 | }, 20 | devtool: "source-map", 21 | target: "node", 22 | externals: [nodeExternals()], 23 | watch: isDev, 24 | plugins: [] // don't remove 25 | }; 26 | 27 | // don't emit files while server bundling 28 | commonWebpackConfig.module.rules.forEach(rule => { 29 | if (rule.use.loader === "file-loader") rule.use.options.emitFile = false; 30 | }); 31 | 32 | module.exports = merge(commonWebpackConfig, localWebpackConfig); 33 | -------------------------------------------------------------------------------- /src/common/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose, applyMiddleware } from "redux"; 2 | import createSagaMiddleware, { END } from "redux-saga"; 3 | 4 | import todoApp from "./Root/screens/TodoApp/reducer"; 5 | 6 | import "./globalStyles"; 7 | import { TRootState, CustomStore } from "./types"; 8 | 9 | export default function configureStore(defaultState?: TRootState) { 10 | const rootReducer = combineReducers({ todoApp }); 11 | const sagaMiddleware = createSagaMiddleware(); 12 | const enhancers = []; 13 | 14 | enhancers.push(applyMiddleware(sagaMiddleware)); 15 | 16 | if ( 17 | typeof window === "object" && 18 | process.env.NODE_ENV === "development" && 19 | window.__REDUX_DEVTOOLS_EXTENSION__ 20 | ) { 21 | enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); 22 | } 23 | 24 | const store = createStore( 25 | rootReducer, 26 | defaultState || {}, 27 | compose(...enhancers) 28 | ) as CustomStore; 29 | 30 | store.runSaga = sagaMiddleware.run; 31 | store.close = () => store.dispatch(END); 32 | 33 | return store; 34 | } 35 | -------------------------------------------------------------------------------- /src/server/routes/todoApp/index.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request } from "express"; 2 | 3 | import todos from "./../../../common/Root/screens/TodoApp/todos"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/", (req, res) => { 8 | res.send(todos); 9 | }); 10 | 11 | router.post("/", (req, res) => { 12 | todos.push(req.body); 13 | }); 14 | 15 | router.param("id", (req, res, next, id) => { 16 | req.todoId = Number.parseInt(id); 17 | if (isNaN(req.todoId)) res.status(400).send("Bad Request"); 18 | else next(); 19 | }); 20 | 21 | router.delete("/:id", (req, res) => { 22 | const idx = todos.findIndex(todo => todo.id === req.todoId); 23 | if (idx !== -1) { 24 | const deletedTodo = todos.splice(idx, 1)[0]; 25 | res.send(deletedTodo); 26 | } else { 27 | res.status(400).send("Bad Request"); 28 | } 29 | }); 30 | 31 | router.patch("/:id", (req, res) => { 32 | const idx = todos.findIndex(todo => todo.id === req.todoId); 33 | if (idx !== -1) { 34 | const todo = todos[idx]; 35 | todo.completed = !todo.completed; 36 | res.send(todo); 37 | } else { 38 | res.status(400).send("Bad Request"); 39 | } 40 | }); 41 | 42 | export default router; 43 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoItem/components/CompletedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Span = styled.span` 5 | margin-right: 16px; 6 | `; 7 | 8 | const CompletedIcon: SFC<{ completed: boolean }> = ({ completed }) => { 9 | const checked = ( 10 | 11 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | const unchecked = ( 25 | 26 | 36 | 37 | ); 38 | 39 | return {completed ? checked : unchecked}; 40 | }; 41 | 42 | export default CompletedIcon; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | build/ 64 | public/ 65 | src/server/routes/SSR/react-loadable.json -------------------------------------------------------------------------------- /src/common/globalStyles.ts: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from "styled-components"; 2 | 3 | import regularWoff from "./assets/fonts/Inter-UI-Regular.woff"; 4 | import regularWoff2 from "./assets/fonts/Inter-UI-Regular.woff2"; 5 | import italicWoff from "./assets/fonts/Inter-UI-Italic.woff"; 6 | import italicWoff2 from "./assets/fonts/Inter-UI-Italic.woff2"; 7 | import boldWoff from "./assets/fonts/Inter-UI-Bold.woff"; 8 | import boldWoff2 from "./assets/fonts/Inter-UI-Bold.woff2"; 9 | 10 | injectGlobal` 11 | @font-face { 12 | font-family: 'Inter UI'; 13 | font-style: normal; 14 | font-weight: 400; 15 | src: url(${regularWoff2}) format("woff2"), 16 | url(${regularWoff}) format("woff"); 17 | } 18 | 19 | @font-face { 20 | font-family: 'Inter UI'; 21 | font-style: italic; 22 | font-weight: 400; 23 | src: url(${italicWoff2}) format("woff2"), 24 | url(${italicWoff}) format("woff"); 25 | } 26 | 27 | @font-face { 28 | font-family: 'Inter UI'; 29 | font-style: normal; 30 | font-weight: 700; 31 | src: url(${boldWoff2}) format("woff2"), 32 | url(${boldWoff}) format("woff"); 33 | } 34 | 35 | * { 36 | box-sizing: border-box; 37 | } 38 | 39 | body { 40 | margin: 0; 41 | font-family: "Inter UI", sans-serif; 42 | background-color: #fafafa; 43 | } 44 | `; 45 | -------------------------------------------------------------------------------- /src/common/Root/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, SFC } from "react"; 2 | import { Route, Redirect, Switch } from "react-router-dom"; 3 | import { hot } from "react-hot-loader"; 4 | import styled, { ThemeProvider } from "styled-components"; 5 | 6 | import MainMenu from "./components/MainMenu/"; 7 | import NotFound from "components/NotFound"; 8 | import Home from "./screens/Home/"; 9 | import Todo from "./screens/TodoApp"; 10 | 11 | import mainMenuItems from "./mainMenuItems"; 12 | import theme from "../theme"; 13 | 14 | const Header = styled.header` 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | height: 60px; 19 | background-color: ${props => props.theme.dark}; 20 | `; 21 | 22 | const Main = styled.main` 23 | width: 600px; 24 | margin: 20px auto 0 auto; 25 | `; 26 | 27 | class Root extends Component { 28 | render() { 29 | return ( 30 | 31 | <> 32 |
33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 |
42 | 43 |
44 | ); 45 | } 46 | } 47 | 48 | export default hot(module)(Root); 49 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled, { css, ThemedStyledProps } from "styled-components"; 3 | 4 | import CompletedIcon from "./components/CompletedIcon"; 5 | import RemoveButton from "./components/RemoveButton"; 6 | 7 | import { TTodoItemProps } from "./types"; 8 | import { TTheme } from "../../../../../types"; 9 | 10 | const Section = styled.section` 11 | display: flex; 12 | align-items: center; 13 | height: 60px; 14 | &:not(:last-child) { 15 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 16 | } 17 | `; 18 | 19 | const Article = styled.article` 20 | flex-grow: 1; 21 | user-select: none; 22 | color: rgba(0, 0, 0, 0.87); 23 | // @ts-ignore 24 | ${(props: { completed: boolean }) => 25 | props.completed && 26 | css` 27 | text-decoration: line-through; 28 | color: rgba(0, 0, 0, 0.38); 29 | font-style: italic; 30 | `}; 31 | `; 32 | 33 | const TodoItem: SFC = ({ 34 | children, 35 | completed, 36 | onRemoveTodo, 37 | onToggleTodo, 38 | id 39 | }) => { 40 | return ( 41 |
{ 43 | onToggleTodo(id, e); 44 | }} 45 | > 46 | 47 |
{children}
48 | { 50 | e.stopPropagation(); 51 | onRemoveTodo(id, e); 52 | }} 53 | /> 54 |
55 | ); 56 | }; 57 | 58 | export default TodoItem; 59 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/TodoInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { TTodoAddProps } from "./types"; 5 | 6 | const Form = styled.form` 7 | display: flex; 8 | box-shadow: 0 4px 16px #efefef; 9 | `; 10 | 11 | const Input = styled.input` 12 | flex: 1; 13 | box-sizing: border-box; 14 | padding-left: 16px; 15 | height: 48px; 16 | background-color: white; 17 | border: none; 18 | border-radius: 12px 0 0 12px; 19 | font-size: 1rem; 20 | &::placeholder { 21 | font-style: italic; 22 | color: ${props => props.theme.grey300}; 23 | } 24 | `; 25 | 26 | const Button = styled.button` 27 | height: 48px; 28 | width: 82px; 29 | background-color: ${props => props.theme.primary}; 30 | border: none; 31 | border-radius: 0 12px 12px 0; 32 | font-size: 1rem; 33 | color: white; 34 | &:hover { 35 | opacity: 0.8; 36 | box-shadow: 0px 4px 16px ${props => props.theme.grey300}; 37 | } 38 | &:active { 39 | opacity: 1; 40 | box-shadow: inset 0 0 0 200px rgba(0, 0, 0, 0.1); 41 | } 42 | `; 43 | 44 | const TodoInput: SFC = ({ 45 | onButtonClick, 46 | onInputChange, 47 | inputValue 48 | }) => { 49 | return ( 50 |
e.preventDefault()}> 51 | 58 | 59 |
60 | ); 61 | }; 62 | 63 | export default TodoInput; 64 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/components/Filters/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SFC } from "react"; 2 | import styled, { css } from "styled-components"; 3 | 4 | import { FilterTypes } from "../../types"; 5 | import { TFilterProps } from "./types"; 6 | 7 | const Input = styled.input` 8 | display: none; 9 | &:checked + label { 10 | color: rgba(0, 0, 0, 0.87); 11 | font-weight: bold; 12 | } 13 | `; 14 | const Label = styled.label` 15 | margin-left: 10px; 16 | color: rgba(0, 0, 0, 0.38); 17 | cursor: pointer; 18 | user-select: none; 19 | `; 20 | 21 | const Filters: SFC = ({ value, onFilterChange }) => { 22 | return ( 23 |
24 | 32 | 33 | 41 | 42 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default Filters; 56 | -------------------------------------------------------------------------------- /config/webpack/client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const merge = require("webpack-merge"); 4 | const globalConfig = require("config"); 5 | const { ReactLoadablePlugin } = require("react-loadable/webpack"); 6 | 7 | const commonWebpackConfig = require("./common"); 8 | 9 | const buildPaths = globalConfig.get("buildPaths"); 10 | 11 | const publicPath = globalConfig.get("publicPath"); 12 | const isDev = process.env.NODE_ENV === "development"; 13 | 14 | const localWebpackConfig = { 15 | mode: isDev ? "development" : "production", 16 | entry: { 17 | main: ["./src/client.tsx"] 18 | }, 19 | output: { 20 | path: path.resolve(buildPaths.client), 21 | publicPath, 22 | filename: isDev ? "[name].bundle.js" : "[name].[chunkhash].js", 23 | chunkFilename: isDev ? "[name].chunk.js" : "[name].[chunkhash].js" 24 | }, 25 | optimization: { 26 | splitChunks: { 27 | chunks: "all" 28 | } 29 | }, 30 | plugins: [ 31 | new ReactLoadablePlugin({ 32 | filename: "./src/server/routes/SSR/react-loadable.json" 33 | }) 34 | ] 35 | }; 36 | 37 | if (isDev) { 38 | localWebpackConfig.entry.main.unshift( 39 | `webpack-dev-server/client?http://localhost:${globalConfig.wdsPort}/`, 40 | "webpack/hot/dev-server" 41 | ); 42 | localWebpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); 43 | localWebpackConfig.devServer = { 44 | publicPath, 45 | port: globalConfig.wdsPort, 46 | hot: true, 47 | overlay: true, 48 | proxy: { 49 | [`!**${publicPath}*`]: `http://localhost:${globalConfig.serverPort}`, 50 | proxyTimeout: 1000 * 60 * 5 51 | }, 52 | quiet: true 53 | }; 54 | } 55 | 56 | module.exports = merge(commonWebpackConfig, localWebpackConfig); 57 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/sagas.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest, takeEvery, call, put, all } from "redux-saga/effects"; 2 | 3 | import * as api from "./apiService"; 4 | import { 5 | ApiRequestTypes, 6 | TAddTodoAction, 7 | ActionTypes, 8 | TRemoveTodoAction, 9 | TToggleTodoAction 10 | } from "./types"; 11 | 12 | export function* fetchTodos() { 13 | try { 14 | const todos = yield call(api.fetchTodos); 15 | yield put({ type: ApiRequestTypes.TODOS_FETCH_SUCCEEDED, todos }); 16 | } catch (error) { 17 | yield put({ type: ApiRequestTypes.TODOS_FETCH_FAILED, error }); 18 | } 19 | } 20 | 21 | export function* postTodo(action: TAddTodoAction) { 22 | try { 23 | const { id, text } = action; 24 | yield call(api.postTodo, { id, text, completed: false }); 25 | yield put({ type: ApiRequestTypes.POST_TODO_SUCCEEDED }); 26 | } catch (error) { 27 | yield put({ type: ApiRequestTypes.POST_TODO_FAILED, error }); 28 | } 29 | } 30 | 31 | export function* removeTodo(action: TRemoveTodoAction) { 32 | try { 33 | const { id } = action; 34 | yield call(api.removeTodo, id); 35 | yield put({ type: ApiRequestTypes.REMOVE_TODO_SUCCEEDED }); 36 | } catch (error) { 37 | yield put({ type: ApiRequestTypes.REMOVE_TODO_FAILED, error }); 38 | } 39 | } 40 | 41 | export function* changeCompleted(action: TToggleTodoAction) { 42 | try { 43 | const { id } = action; 44 | yield call(api.changeCompleted, id); 45 | yield put({ type: ApiRequestTypes.CHANGE_COMPLETED_SUCCEEDED }); 46 | } catch (error) { 47 | yield put({ type: ApiRequestTypes.CHANGE_COMPLETED_FAILED, error }); 48 | } 49 | } 50 | 51 | export default function* todoAppSaga() { 52 | yield all([ 53 | takeLatest(ApiRequestTypes.TODOS_FETCH_REQUESTED, fetchTodos), 54 | takeEvery(ActionTypes.ADD_TODO, postTodo), 55 | takeEvery(ActionTypes.REMOVE_TODO, removeTodo), 56 | takeEvery(ActionTypes.TOGGLE_TODO, changeCompleted) 57 | ]); 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-full-stack-boilerplate", 3 | "version": "1.0.0", 4 | "description": "Typescript full-stack boilerplate", 5 | "author": "Sergei Samsonov (https://github.com/osenvosem)", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "run-s start:dev", 9 | "start:dev": "NODE_ENV=development node scripts/start.js", 10 | "build": "NODE_ENV=production node scripts/build.js", 11 | "start:prod": "NODE_ENV=production node build/server.bundle.js", 12 | "clean": "node scripts/clean", 13 | "build:start:prod": "run-s clean build start:prod" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.0.0-beta.49", 17 | "@babel/node": "^7.0.0-beta.49", 18 | "@babel/preset-env": "^7.0.0-beta.49", 19 | "@babel/preset-react": "^7.0.0-beta.49", 20 | "@babel/preset-stage-2": "^7.0.0-beta.49", 21 | "@babel/preset-typescript": "^7.0.0-beta.49", 22 | "@types/axios": "^0.14.0", 23 | "@types/config": "^0.0.34", 24 | "@types/express": "^4.16.0", 25 | "@types/node": "^10.3.2", 26 | "@types/react": "^16.3.17", 27 | "@types/react-dom": "^16.0.6", 28 | "@types/react-loadable": "^5.4.0", 29 | "@types/react-redux": "^6.0.2", 30 | "@types/react-router": "^4.0.26", 31 | "@types/react-router-dom": "^4.2.7", 32 | "@types/webpack": "^4.4.0", 33 | "@types/webpack-env": "^1.13.6", 34 | "babel-loader": "^8.0.0-beta.3", 35 | "babel-polyfill": "^6.26.0", 36 | "chalk": "^2.4.1", 37 | "file-loader": "^1.1.11", 38 | "nodemon": "^1.17.5", 39 | "npm-run-all": "^4.1.3", 40 | "react-dev-utils": "^5.0.1", 41 | "react-hot-loader": "^4.3.0", 42 | "webpack": "^4.12.0", 43 | "webpack-dev-server": "^3.1.4", 44 | "webpack-merge": "^4.1.1", 45 | "webpack-node-externals": "^1.7.2" 46 | }, 47 | "dependencies": { 48 | "axios": "^0.18.0", 49 | "config": "^1.30.0", 50 | "express": "^4.16.3", 51 | "react": "^16.4.0", 52 | "react-dom": "^16.4.0", 53 | "react-loadable": "^5.4.0", 54 | "react-redux": "^5.0.7", 55 | "react-router-dom": "^4.3.1", 56 | "redux": "^4.0.0", 57 | "redux-saga": "^0.16.0", 58 | "styled-components": "^3.3.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TTodoAppState, 3 | TTodo, 4 | TTodoActions, 5 | ActionTypes, 6 | FilterTypes, 7 | TFilterActions, 8 | TApiFetchActions, 9 | ApiRequestTypes 10 | } from "./types"; 11 | import { generateId } from "./utils"; 12 | 13 | export function filter(state: FilterTypes, action: TFilterActions) { 14 | return action.type; 15 | } 16 | 17 | export function todos(state: TTodo[], action: TTodoActions): TTodo[] { 18 | switch (action.type) { 19 | case ActionTypes.ADD_TODO: 20 | const { text, id } = action; 21 | return [...state, { text, id, completed: false }]; 22 | case ActionTypes.REMOVE_TODO: 23 | const idx = state.findIndex(todo => todo.id === action.id); 24 | return [...state.slice(0, idx), ...state.slice(idx + 1)]; 25 | case ActionTypes.TOGGLE_TODO: 26 | return state.map(todo => { 27 | if (todo.id === action.id) { 28 | return { ...todo, completed: !todo.completed }; 29 | } 30 | return todo; 31 | }); 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | export function todosFetch(state: TTodoAppState, action: TApiFetchActions) { 38 | switch (action.type) { 39 | case ApiRequestTypes.TODOS_FETCH_REQUESTED: 40 | return { todosRequested: true }; 41 | case ApiRequestTypes.TODOS_FETCH_SUCCEEDED: 42 | return { todos: action.todos, todosRequested: false }; 43 | case ApiRequestTypes.TODOS_FETCH_FAILED: 44 | return { todosRequested: false, error: action.error }; 45 | default: 46 | return state; 47 | } 48 | } 49 | 50 | export default function todoAppReducer( 51 | state: TTodoAppState = { 52 | todos: [], 53 | filter: FilterTypes.SHOW_ALL, 54 | todosRequested: false 55 | }, 56 | action: TTodoActions | any // solves the issue with combineReducers 57 | ) { 58 | switch (action.type) { 59 | case ActionTypes.ADD_TODO: 60 | case ActionTypes.REMOVE_TODO: 61 | case ActionTypes.TOGGLE_TODO: 62 | return { ...state, todos: todos(state.todos, action) }; 63 | case FilterTypes.SHOW_COMPLETED: 64 | case FilterTypes.SHOW_INCOMPLETE: 65 | case FilterTypes.SHOW_ALL: 66 | return { ...state, filter: filter(state.filter, action) }; 67 | case ApiRequestTypes.TODOS_FETCH_REQUESTED: 68 | case ApiRequestTypes.TODOS_FETCH_SUCCEEDED: 69 | case ApiRequestTypes.TODOS_FETCH_FAILED: 70 | return { ...state, ...todosFetch(state, action) }; 71 | default: 72 | return state; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/server/routes/SSR/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { renderToString } from "react-dom/server"; 3 | import { StaticRouter } from "react-router"; 4 | import { Application, Handler } from "express"; 5 | import Loadable from "react-loadable"; 6 | import { getBundles } from "react-loadable/webpack"; 7 | import stats from "./react-loadable.json"; 8 | import config from "config"; 9 | import { Provider } from "react-redux"; 10 | import { ServerStyleSheet } from "styled-components"; 11 | 12 | import Main from "../../../common/Root"; 13 | import { TStaticContext } from "./types"; 14 | import configureStore from "../../../common/configureStore"; 15 | import todoAppSaga from "../../../common/Root/screens/TodoApp/sagas"; 16 | 17 | const assets: string[] = JSON.parse(CLIENT_ASSETS).filter((asset: string) => 18 | /.js$/.test(asset) 19 | ); 20 | 21 | const SSRHandler: Handler = (req, res, next) => { 22 | const context: TStaticContext = {}; 23 | const modules: string[] = []; 24 | const store = configureStore(); 25 | const sheet = new ServerStyleSheet(); 26 | 27 | const rootComp = ( 28 | 29 | 30 | modules.push(moduleName)}> 31 |
32 | 33 | 34 | 35 | ); 36 | 37 | // this may not be needed 38 | const bundles: { file: string }[] = getBundles(stats, modules); 39 | 40 | if (context.url) { 41 | res.redirect(context.status || 301, context.url); 42 | } else { 43 | store.runSaga(todoAppSaga).done.then(() => { 44 | const html = ` 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Typescript boilerplate 53 | ${sheet.getStyleTags()} 54 | 55 | 56 | 57 |
${renderToString(rootComp)}
58 | 59 | 62 | 71 | ${assets 72 | .map(assetPath => { 73 | return `\n`; 74 | }) 75 | .join("\n")} 76 | 77 | 78 | `; 79 | res.send(html); 80 | }); 81 | renderToString(sheet.collectStyles(rootComp)); 82 | store.close(); 83 | } 84 | }; 85 | 86 | export default SSRHandler; 87 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, KeyboardEvent } from "react"; 2 | import { connect } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import * as actions from "./actionCreators"; 6 | import TodoList from "./components/TodoList"; 7 | import TodoInput from "./components/TodoInput"; 8 | import Filters from "./components/Filters"; 9 | import ActiveItemsCount from "./components/ActiveItemsCount"; 10 | import { filterTodos, generateId } from "./utils"; 11 | 12 | import { TRootState } from "../../../types"; 13 | import { 14 | TProps, 15 | TState, 16 | TTodo, 17 | TAddTodoHandler, 18 | TRemoveTodoHandler, 19 | TToggleTodoHandler, 20 | TInputChangeHandler, 21 | FilterTypes, 22 | TFilterChangeHandler 23 | } from "./types"; 24 | 25 | const BottomPanel = styled.section` 26 | display: flex; 27 | height: 60px; 28 | align-items: center; 29 | justify-content: space-between; 30 | color: rgba(0, 0, 0, 0.87); 31 | `; 32 | 33 | class Todo extends Component { 34 | readonly state = { inputValue: "" }; 35 | 36 | componentWillMount() { 37 | if (!this.props.todos.length) this.props.fetchTodos(); 38 | } 39 | 40 | handleAddTodo: TAddTodoHandler = e => { 41 | const { inputValue } = this.state; 42 | if (!inputValue.length) return; 43 | const id = generateId(this.props.todos); 44 | this.props.addTodo(inputValue, id); 45 | this.setState({ inputValue: "" }); 46 | }; 47 | 48 | handleRemoveTodo: TRemoveTodoHandler = id => { 49 | this.props.removeTodo(id); 50 | }; 51 | 52 | handleToggleTodo: TToggleTodoHandler = id => { 53 | this.props.toggleTodo(id); 54 | }; 55 | 56 | handleInputChange: TInputChangeHandler = e => { 57 | if (e.key === "Enter") this.handleAddTodo(e); 58 | else this.setState({ inputValue: e.currentTarget.value }); 59 | }; 60 | 61 | handleFilterChange: TFilterChangeHandler = e => { 62 | const filter = e.currentTarget.dataset.filter; 63 | if (filter) { 64 | this.props.changeFilter(filter); 65 | } 66 | }; 67 | 68 | render() { 69 | let todos = filterTodos(this.props.todos, this.props.filter); 70 | const itemsLeft = this.props.todos.filter(todo => !todo.completed).length; 71 | return ( 72 | <> 73 | 78 | {this.props.todosRequested ?

Loading...

: null} 79 | 85 | 86 | 87 | 91 | 92 | 93 | ); 94 | } 95 | } 96 | 97 | const mapStateToProps = (state: TRootState) => ({ 98 | ...state.todoApp 99 | }); 100 | 101 | export default connect(mapStateToProps, actions)(Todo); 102 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const fs = require("fs"); 3 | const chalk = require("chalk"); 4 | const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages"); 5 | const clearConsole = require("react-dev-utils/clearConsole"); 6 | const config = require("config"); 7 | 8 | const clientConfig = require("../config/webpack/client"); 9 | const serverConfig = require("../config/webpack/server"); 10 | 11 | const WAIT = chalk`{bgBlue.black WAIT } {blue Compilation...}`; 12 | const DONE = chalk`{bgGreen.black DONE }`; 13 | const ERROR = chalk`{bgRed.black ERROR }`; 14 | const WARNING = chalk`{bgYellow.black WARNING }`; 15 | 16 | const publicPath = config.get("publicPath"); 17 | 18 | const capitalize = str => { 19 | str = str.trim(); 20 | return str.charAt(0).toUpperCase() + str.slice(1); 21 | }; 22 | 23 | const printErrors = (side, errors) => { 24 | clearConsole(); 25 | console.log( 26 | ERROR, 27 | chalk`{red ${capitalize(side)} compilation failed with ${ 28 | errors.length > 1 ? `${errors.length} errors` : `an error` 29 | }\n}` 30 | ); 31 | errors.forEach(err => console.log(err)); 32 | }; 33 | 34 | const printWarnings = (side, warnings) => { 35 | clearConsole(); 36 | console.log( 37 | WARNING, 38 | chalk`{yellow ${capitalize(side)} compilation completed with ${ 39 | warnings.length > 1 ? `${warnings.length} warnings` : `a warning` 40 | }\n}` 41 | ); 42 | warnings.forEach(err => console.log(err)); 43 | }; 44 | 45 | /* CLIENT */ 46 | 47 | webpack(clientConfig, (err, clientStats) => { 48 | if (err) { 49 | clearConsole(); 50 | console.log(ERROR); 51 | console.error(err.stack || err); 52 | if (err.details) { 53 | console.error(err.details); 54 | } 55 | return; 56 | } 57 | 58 | const clientInfo = clientStats.toJson({}, true); 59 | 60 | if (clientStats.hasErrors()) { 61 | printErrors("client", formatWebpackMessages(clientInfo).errors); 62 | return; 63 | } 64 | 65 | if (clientStats.hasWarnings()) { 66 | printWarnings("client", formatWebpackMessages(clientInfo).warnings); 67 | } 68 | 69 | const clientAssets = clientInfo.assets.map(assetObj => { 70 | return `${publicPath}${assetObj.name}`; 71 | }); 72 | 73 | console.log( 74 | DONE, 75 | chalk`{green The client production bundle compiled successfully.}` 76 | ); 77 | 78 | /* SERVER */ 79 | 80 | serverConfig.plugins.push( 81 | new webpack.DefinePlugin({ 82 | CLIENT_ASSETS: JSON.stringify(JSON.stringify(clientAssets)) 83 | }) 84 | ); 85 | 86 | const serverCompiler = webpack(serverConfig, (err, serverStats) => { 87 | if (err) { 88 | clearConsole(); 89 | console.log(ERROR); 90 | console.error(err.stack || err); 91 | if (err.details) { 92 | console.error(err.details); 93 | } 94 | return; 95 | } 96 | 97 | const serverInfo = serverStats.toJson({}, true); 98 | 99 | if (serverStats.hasErrors()) { 100 | printErrors("server", formatWebpackMessages(serverInfo).errors); 101 | return; 102 | } 103 | 104 | if (serverStats.hasWarnings()) { 105 | printWarnings("server", formatWebpackMessages(serverInfo).warnings); 106 | } 107 | 108 | console.log( 109 | DONE, 110 | chalk`{green The server production bundle compiled successfully.}` 111 | ); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/common/Root/screens/TodoApp/types.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent, MouseEvent, KeyboardEvent, ChangeEvent } from "react"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | 4 | export interface TTodo { 5 | readonly text: string; 6 | readonly completed: boolean; 7 | readonly id: number; 8 | } 9 | 10 | export interface TProps extends RouteComponentProps<{}> { 11 | readonly todos: TTodo[]; 12 | readonly filter: FilterTypes; 13 | readonly addTodo: (text: string, id: number) => TAddTodoAction; 14 | readonly removeTodo: (id: number) => TRemoveTodoAction; 15 | readonly toggleTodo: (id: number) => TToggleTodoAction; 16 | readonly changeFilter: (filter: string) => TFilterActions; 17 | readonly fetchTodos: () => TFetchRequestedAction; 18 | readonly todosRequested: boolean; 19 | readonly error?: Error; 20 | } 21 | 22 | export interface TState { 23 | readonly inputValue: string; 24 | } 25 | 26 | export interface TTodoAppState { 27 | readonly todos: TTodo[]; 28 | readonly filter: FilterTypes; 29 | readonly todosRequested: boolean; 30 | readonly error?: Error; 31 | } 32 | 33 | // Actions 34 | 35 | export enum ActionTypes { 36 | ADD_TODO = "todoApp/ADD_TODO", 37 | REMOVE_TODO = "todoApp/REMOVE_TODO", 38 | TOGGLE_TODO = "todoApp/TOGGLE_TODO" 39 | } 40 | 41 | export interface TAddTodoAction { 42 | readonly type: ActionTypes.ADD_TODO; 43 | readonly text: string; 44 | id: number; 45 | } 46 | 47 | export interface TRemoveTodoAction { 48 | readonly type: ActionTypes.REMOVE_TODO; 49 | readonly id: number; 50 | } 51 | 52 | export interface TToggleTodoAction { 53 | readonly type: ActionTypes.TOGGLE_TODO; 54 | readonly id: number; 55 | } 56 | 57 | export type TTodoActions = 58 | | TAddTodoAction 59 | | TRemoveTodoAction 60 | | TToggleTodoAction; 61 | 62 | // Action creators 63 | 64 | export interface TAddTodoActionCreator { 65 | (text: string, id: number): TAddTodoAction; 66 | } 67 | 68 | export interface TRemoveTodoActionCreator { 69 | (id: number): TRemoveTodoAction; 70 | } 71 | 72 | export interface TToggleTodoActionCreator { 73 | (id: number): TToggleTodoAction; 74 | } 75 | 76 | // Handlers 77 | 78 | export interface TAddTodoHandler { 79 | (e: FormEvent): void; 80 | } 81 | 82 | export interface TRemoveTodoHandler { 83 | (id: number, e: MouseEvent): void; 84 | } 85 | 86 | export interface TToggleTodoHandler { 87 | (id: number, e: FormEvent): void; 88 | } 89 | 90 | export interface TInputChangeHandler { 91 | (e: ChangeEvent & KeyboardEvent): void; 92 | } 93 | 94 | // filters 95 | 96 | export enum FilterTypes { 97 | SHOW_COMPLETED = "todoApp/SHOW_COMPLETED", 98 | SHOW_INCOMPLETE = "todoApp/SHOW_INCOMPLETE", 99 | SHOW_ALL = "todoApp/SHOW_ALL" 100 | } 101 | 102 | export interface TFilterActions { 103 | type: FilterTypes; 104 | } 105 | 106 | export interface TFilterChangeHandler { 107 | (e: ChangeEvent): void; 108 | } 109 | 110 | export interface TFilterChangeActionCreator { 111 | (filter: FilterTypes): TFilterActions; 112 | } 113 | 114 | // API 115 | 116 | export enum ApiRequestTypes { 117 | TODOS_FETCH_REQUESTED = "TODOS_FETCH_REQUESTED", 118 | TODOS_FETCH_SUCCEEDED = "TODOS_FETCH_SUCCEEDED", 119 | TODOS_FETCH_FAILED = "TODOS_FETCH_FAILED", 120 | POST_TODO_SUCCEEDED = "POST_TODO_SUCCEEDED", 121 | POST_TODO_FAILED = "POST_TODO_FAILED", 122 | REMOVE_TODO_SUCCEEDED = "REMOVE_TODO_SUCCEEDED", 123 | REMOVE_TODO_FAILED = "REMOVE_TODO_FAILED", 124 | CHANGE_COMPLETED_SUCCEEDED = "CHANGE_COMPLETED_SUCCEEDED", 125 | CHANGE_COMPLETED_FAILED = "CHANGE_COMPLETED_FAILED" 126 | } 127 | 128 | export interface TFetchRequestedAction { 129 | type: ApiRequestTypes.TODOS_FETCH_REQUESTED; 130 | } 131 | 132 | export interface TFetchSuccededAction { 133 | type: ApiRequestTypes.TODOS_FETCH_SUCCEEDED; 134 | todos: TTodo[]; 135 | } 136 | 137 | export interface TFetchFailedAction { 138 | type: ApiRequestTypes.TODOS_FETCH_FAILED; 139 | error: Error; 140 | } 141 | 142 | export type TApiFetchActions = 143 | | TFetchRequestedAction 144 | | TFetchSuccededAction 145 | | TFetchFailedAction; 146 | 147 | export interface TFetchRequestedActionCreator { 148 | (): TFetchRequestedAction; 149 | } 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A full-stack typescript boilerplate that primarily was created for the purpose of training. 2 | 3 | ## What's inside (will be complemented as the boilerplate evolves) 4 | 5 | * Webpack 4 and Babel 7 with typescript, env and stage-2 presets; 6 | * React, React Router, React Loadable, Redux, Redux Saga, Styled Components; 7 | * Express; 8 | * Server side rendering; 9 | * Client side hot module reloading; 10 | * Friendly errors; 11 | 12 | ## Scripts 13 | 14 | ```sh 15 | # start in development mode (on port 3000 by default) 16 | npm start | yarn start 17 | 18 | # build client and server production bundles 19 | npm run build | yarn build 20 | 21 | # clean generated bundles 22 | npm run clean | yarn clean 23 | 24 | # start in production mode (on port 8080 by default) 25 | npm run start:prod | yarn start:prod 26 | 27 | # build and start the app in production mode 28 | npm run build:start:prod | yarn build:start:prod 29 | ``` 30 | 31 | ## The project structure 32 | 33 | ```sh 34 | . 35 | ├── README.md 36 | ├── build # server builds 37 | ├── config # global and webpack configuration 38 | │   ├── default.json 39 | │   ├── development.json 40 | │   ├── production.json 41 | │   └── webpack 42 | │   ├── client.js 43 | │   ├── common.js 44 | │   └── server.js 45 | ├── package.json 46 | ├── public # production build folder for the client code 47 | ├── scripts # development scripts 48 | │   ├── build.js 49 | │   ├── clean.js 50 | │   └── start.js 51 | ├── src 52 | │   ├── client.tsx # client entry point 53 | │   ├── common # universal code 54 | │   │   ├── Root 55 | │   │   │   ├── components 56 | │   │   │   │   └── MainMenu 57 | │   │   │   │   ├── index.tsx 58 | │   │   │   │   └── types.ts 59 | │   │   │   ├── index.tsx 60 | │   │   │   ├── mainMenuItems.ts 61 | │   │   │   └── screens 62 | │   │   │   ├── Home 63 | │   │   │   │   ├── components 64 | │   │   │   │   │   ├── CatImage.tsx 65 | │   │   │   │   │   └── cat.png 66 | │   │   │   │   └── index.tsx 67 | │   │   │   └── TodoApp 68 | │   │   │   ├── actionCreators.ts 69 | │   │   │   ├── apiService.ts 70 | │   │   │   ├── components 71 | │   │   │   │   ├── ActiveItemsCount 72 | │   │   │   │   │   ├── index.tsx 73 | │   │   │   │   │   └── types.ts 74 | │   │   │   │   ├── Filters 75 | │   │   │   │   │   ├── index.tsx 76 | │   │   │   │   │   └── types.ts 77 | │   │   │   │   ├── TodoInput 78 | │   │   │   │   │   ├── index.tsx 79 | │   │   │   │   │   └── types.ts 80 | │   │   │   │   ├── TodoItem 81 | │   │   │   │   │   ├── components 82 | │   │   │   │   │   │   ├── CompletedIcon.tsx 83 | │   │   │   │   │   │   └── RemoveButton.tsx 84 | │   │   │   │   │   ├── index.tsx 85 | │   │   │   │   │   └── types.ts 86 | │   │   │   │   └── TodoList 87 | │   │   │   │   ├── index.tsx 88 | │   │   │   │   └── types.ts 89 | │   │   │   ├── index.tsx 90 | │   │   │   ├── reducer.ts 91 | │   │   │   ├── sagas.ts 92 | │   │   │   ├── todos.ts 93 | │   │   │   ├── types.ts 94 | │   │   │   └── utils.ts 95 | │   │   ├── assets 96 | │   │   │   └── fonts 97 | │   │   │   ├── Inter-UI-Bold.woff 98 | │   │   │   ├── Inter-UI-Bold.woff2 99 | │   │   │   ├── Inter-UI-Italic.woff 100 | │   │   │   ├── Inter-UI-Italic.woff2 101 | │   │   │   ├── Inter-UI-Regular.woff 102 | │   │   │   └── Inter-UI-Regular.woff2 103 | │   │   ├── configureStore.ts 104 | │   │   ├── globalStyles.ts 105 | │   │   ├── shared 106 | │   │   │   ├── components 107 | │   │   │   │   ├── NotFound 108 | │   │   │   │   │   └── index.tsx 109 | │   │   │   │   └── RedirectWithStatus 110 | │   │   │   │   ├── index.tsx 111 | │   │   │   │   └── interfaces.ts 112 | │   │   │   ├── services 113 | │   │   │   └── utils 114 | │   │   ├── theme.ts 115 | │   │   └── types.ts 116 | │   └── server 117 | │   ├── index.ts 118 | │   ├── routes 119 | │   │   ├── SSR 120 | │   │   │   ├── index.tsx 121 | │   │   │   ├── react-loadable.json 122 | │   │   │   └── types.ts 123 | │   │   ├── index.ts 124 | │   │   └── todoApp 125 | │   │   ├── index.ts 126 | │   │   └── types.ts 127 | │   └── server.ts 128 | ├── tsconfig.json 129 | ├── types.d.ts 130 | └── yarn.lock 131 | ``` 132 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const cp = require("child_process"); 4 | const webpack = require("webpack"); 5 | const WebpackDevServer = require("webpack-dev-server"); 6 | const globalConfig = require("config"); 7 | const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages"); 8 | const clearConsole = require("react-dev-utils/clearConsole"); 9 | const chalk = require("chalk"); 10 | const nodemon = require("nodemon"); 11 | 12 | const buildPaths = globalConfig.get("buildPaths"); 13 | const publicPath = globalConfig.get("publicPath"); 14 | const clientConfig = require("../config/webpack/client"); 15 | const serverConfig = require("../config/webpack/server"); 16 | 17 | let clientCompilationMessages = { errors: [], warnings: [] }; 18 | let serverCompilationMessages = { errors: [], warnings: [] }; 19 | let serverCompilationErrorHappened = false; 20 | 21 | let serverWebpackWatcher = null; 22 | let nodemonProcess = null; 23 | 24 | /* UTILS */ 25 | 26 | const printWait = side => { 27 | if (typeof side !== "string") 28 | throw new Error( 29 | `The first argument must be a string, ${typeof side} given` 30 | ); 31 | 32 | console.log( 33 | chalk`{bgBlue.black ${side.toUpperCase()} } {blue compilation...}` 34 | ); 35 | }; 36 | 37 | const printDone = side => { 38 | if (typeof side !== "string") 39 | throw new Error( 40 | `The first argument must be a string, ${typeof side} given` 41 | ); 42 | 43 | console.log( 44 | chalk`{bgGreen.black ${side.toUpperCase()} } {green compiled successfully}` 45 | ); 46 | }; 47 | 48 | const printErrors = (side, errors) => { 49 | if (typeof side !== "string") 50 | throw new Error( 51 | `The first argument must be a string, ${typeof side} given` 52 | ); 53 | 54 | if (!Array.isArray(errors)) 55 | throw new Error( 56 | `The second argument must be an array, ${typeof errors} given` 57 | ); 58 | 59 | console.log( 60 | chalk`{bgRed.black ${side.toUpperCase()} } {red failed with ${ 61 | errors.length > 1 ? `${errors.length} errors` : `an error` 62 | }\n}` 63 | ); 64 | errors.forEach(err => console.log(err)); 65 | }; 66 | 67 | const printWarnings = (side, warnings) => { 68 | if (typeof side !== "string") 69 | throw new Error( 70 | `The first argument must be a string, ${typeof side} given` 71 | ); 72 | 73 | if (!Array.isArray(warnings)) 74 | throw new Error( 75 | `The second argument must be an array, ${typeof warnings} given` 76 | ); 77 | 78 | console.log( 79 | chalk`{bgYellow.black ${side.toUpperCase()} } {yellow completed with ${ 80 | warnings.length > 1 ? `${warnings.length} warnings` : `a warning` 81 | }\n}` 82 | ); 83 | warnings.forEach(err => console.log(err)); 84 | }; 85 | 86 | /* CLIENT */ 87 | 88 | const clientCompiler = webpack(clientConfig); 89 | 90 | clientCompiler.hooks.invalid.tap({ name: "invalid" }, () => { 91 | printWait("client"); 92 | }); 93 | 94 | clientCompiler.hooks.done.tap({ name: "done" }, clientStats => { 95 | const clientInfo = clientStats.toJson({}, true); 96 | 97 | clientCompilationMessages = formatWebpackMessages(clientInfo); 98 | 99 | const clientAssets = clientInfo.assets.map( 100 | assetObj => `${publicPath}${assetObj.name}` 101 | ); 102 | 103 | if (clientStats.hasErrors() || clientStats.hasWarnings()) { 104 | if (clientStats.hasErrors()) { 105 | printErrors("client compilation", clientCompilationMessages.errors); 106 | clientCompilationMessages.errors = []; 107 | } 108 | 109 | if (clientStats.hasWarnings()) { 110 | printWarnings("client compilation", clientCompilationMessages.warnings); 111 | clientCompilationMessages.warnings = []; 112 | } 113 | } else { 114 | printDone("client"); 115 | } 116 | 117 | if (serverWebpackWatcher === null) { 118 | /* SERVER */ 119 | 120 | serverConfig.plugins.push( 121 | new webpack.DefinePlugin({ 122 | CLIENT_ASSETS: JSON.stringify(JSON.stringify(clientAssets)) 123 | }) 124 | ); 125 | 126 | const serverCompiler = webpack(serverConfig); 127 | 128 | serverCompiler.hooks.invalid.tap({ name: "invalid" }, () => { 129 | printWait("server"); 130 | }); 131 | 132 | // timefix 133 | const timefix = 11000; 134 | serverCompiler.hooks.done.tap({ name: "done" }, (err, stats) => { 135 | stats.startTime -= timefix; 136 | }); 137 | 138 | serverWebpackWatcher = serverCompiler.watch(null, (err, serverStats) => { 139 | if (err) { 140 | console.error(err.stack || err); 141 | if (err.details) { 142 | console.error(err.details); 143 | } 144 | return; 145 | } 146 | 147 | // timefix 148 | serverWebpackWatcher.startTime += timefix; 149 | 150 | const serverInfo = serverStats.toJson({}, true); 151 | 152 | serverCompilationMessages = formatWebpackMessages(serverInfo); 153 | 154 | if (serverStats.hasErrors() || serverStats.hasWarnings()) { 155 | if (serverStats.hasErrors()) { 156 | clearConsole(); 157 | printErrors("server compilation", serverCompilationMessages.errors); 158 | serverCompilationMessages.errors = []; 159 | serverCompilationErrorHappened = true; 160 | return; 161 | } 162 | 163 | if (serverStats.hasWarnings()) { 164 | printWarnings( 165 | "server compilation", 166 | serverCompilationMessages.warnings 167 | ); 168 | serverCompilationMessages.warnings = []; 169 | } 170 | } else { 171 | serverCompilationErrorHappened = false; 172 | printDone("server"); 173 | } 174 | 175 | // run the server bundle 176 | 177 | // get the server bundle name 178 | const serverFilename = serverInfo.assets 179 | .map(assetObj => assetObj.name) 180 | .filter(filename => /\.js$/.test(filename))[0]; 181 | 182 | if (typeof serverFilename === "undefined") { 183 | throw new Error("Failed to get server bundle name."); 184 | } 185 | 186 | if (!nodemonProcess) { 187 | nodemonProcess = nodemon({ 188 | script: `${buildPaths.server}/${serverFilename}`, 189 | stdout: false 190 | }); 191 | nodemonProcess.on("readable", function() { 192 | this.stdout.pipe(process.stdout); 193 | 194 | this.stderr.on("data", data => { 195 | if (serverCompilationErrorHappened) return; 196 | printErrors("server runtime", [data.toString()]); 197 | }); 198 | }); 199 | } 200 | }); 201 | } 202 | }); 203 | 204 | const devServer = new WebpackDevServer(clientCompiler, clientConfig.devServer); 205 | devServer.listen(globalConfig.get("wdsPort"), null, err => { 206 | if (err) return console.error(err); 207 | }); 208 | 209 | ["SIGINT", "SIGTERM"].forEach(sig => { 210 | process.on(sig, () => { 211 | process.exit(); 212 | devServer.close(); 213 | }); 214 | }); 215 | --------------------------------------------------------------------------------