= ({ 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 |
--------------------------------------------------------------------------------