This article guides you through setting up to build a basic cloud-native Web application using K8S, Docker, Yarn Workspace, TypeScript, ESBuild, Express, and React. By the end of this tutorial, you will have a Web application that can be fully built and deployed on K8S.
Setting up the project
The project will be constructed as Monorepo. The goal of Monorepo is to increase the amount of code shared between modules and to better predict how these modules will communicate together (for example, in microservices architectures). For the purposes of this exercise, we will keep the structure simple:
app
It will represent ourReact website
.server
, it will useExpress
Serving usapp
.common
, some of the code will be inapp
和server
Shared between.
The only requirement before setting up the project is to install YARN on the machine. Yarn is a package manager like NPM, but with better performance and slightly more functionality. You can read more about how to install it in the official documentation.
Workspaces
Go to the folder where you want to initialize the project, and then perform the following steps from your favorite terminal:
- use
mkdir my-app
Create a folder for the project (you are free to choose the name you want). - use
cd my-app
Go to the folder. - use
yarn init
Initialize it. This will prompt you to create the initialpackage.json
File-related issues (don’t worry, once you create a file, you can modify it at any time). If you do not want to useyarn init
Command, you can always manually create a file and copy the following into it:
{
"name": "my-app"."version": "1.0.0"."license": "UNLICENSED"."private": true // Required for yarn workspace to work
}
Copy the code
Now that we have created the package.json file, we need to create folders for our modules app, Common, and Server. To make it easier for YARN Workspace to find modules and improve readability of projects, we nested modules under packages:
My - app / ├ ─ packages /// Where all of our current and future modules will live│ ├─ App / │ ├─ Common / │ ├─ Server / ├─ package.jsonCopy the code
Each of our modules will act as a small, independent project and will need its own package.json to manage dependencies. To set each of them, we can either use YARN init (in each folder) or create files manually (for example, through an IDE).
The naming convention used for package names is to prefix each package with @my-app/*. This is called scope in the NPM world (you can read more about it here). You don’t have to prefix yourself like this, but it will help later.
Once you have created and initialized all three packages, you will have the similarities shown below.
The app package:
{
"name": "@my-app/app"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code
Common package:
{
"name": "@my-app/common"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code
Server package:
{
"name": "@my-app/server"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code
Finally, we need to tell YARN where to look for the module, so go back and edit the project’s package.json file and add the following Workspaces properties (see YARN’s Workspaces documentation if you want more details).
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"] // Add it here
}
Copy the code
Your final folder structure should look like this:
My - app / ├ ─ packages / │ ├ ─ app / │ │ ├ ─ package. The json │ ├ ─ common / │ │ ├ ─ package. The json │ ├ ─ server / │ │ ├ ─ package. The json ├ ─ package.jsonCopy the code
You have now completed the basic setup of the project.
TypeScript
Now let’s add our first dependency to our project: TypeScript. TypeScript is a superset of JavaScript that implements type checking at build time.
Go to the root directory of the project on the terminal and run yarn add -d -w typescript.
- parameter
-D
将TypeScript
Added to thedevDependencies
Because we only use it during development and build. - parameter
-W
Allows a package to be installed in the workspace root directory so that theapp
,common
和server
Is available globally.
Your package.json should look like this:
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
"typescript": "^ holdings"}}Copy the code
This also creates a yarn.lock file (which ensures that the expected version of the dependency remains the same throughout the life of the project) and a node_modules folder that holds the binaries of the dependency.
Now that we have TypeScript installed, it’s a good habit to tell it how to run. To do this, we’ll add a configuration file that should be picked up by your IDE (or automatically if you use VSCode).
Create a tsconfig.json file in the root directory of your project and copy the following into it:
{
"compilerOptions": {
/* Basic */
"target": "es2017"."module": "CommonJS"."lib": ["ESNext"."DOM"]./* Modules Resolution */
"moduleResolution": "node"."esModuleInterop": true./* Paths Resolution */
"baseUrl": ". /"."paths": {
"@flipcards/*": ["packages/*"]},/* Advanced */
"jsx": "react"."experimentalDecorators": true."resolveJsonModule": true
},
"exclude": ["node_modules"."**/node_modules/*"."dist"]}Copy the code
You can easily search each compileOptions property and its actions, but the paths property is most useful to us. For example, this tells TypeScript where to look for code and Typings when importing @my-app/common in @my-app/server or @my-app/app packages.
Your current project structure should now look like this:
│ ├─ Common / │ │ ├─ Package. json │ │ │ ├─ Server │ │ ├─ ─ │ │ │ ├─ Package │ │ │ ├─ package │ │ │ │ ├─ package │ │ │ │ ├─ package │ │ │ │ ├─ Package. json ├─ Package. json ├─ TsConfig. json ├─ yarnCopy the code
Add the first script
Yarn Workspace allows us to access any subpackage through the Yarn Workspace @my-app/* command mode, but typing the full command each time becomes redundant. To do this, we can create helper Script methods to improve the development experience. Open package.json in the project root directory and add the following scripts properties to it.
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
"typescript": "^ holdings"
},
"scripts": {
"app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"}}Copy the code
You can now execute any command as in a subpackage. For example, you can add some new dependencies by typing YARN Server Add Express. This adds new dependencies directly to the Server package.
In the following sections, we’ll start building front-end and back-end applications.
Ready to Git
If you plan to use Git as a version control tool, it is strongly recommended that you ignore generated files, such as binaries or logs.
To do this, create a new file named.gitignore in the root directory of your project and copy the following into it. This will ignore some of the files that will be generated later in the tutorial and avoid committing a lot of unnecessary data.
# Logs
yarn-debug.log*
yarn-error.log*
# Binaries
node_modules/
# Builds
dist/
**/public/script.js
Copy the code
The folder structure should look like this:
My-app / ├─ Packages / ├─.gitignore ├─ package.jsonCopy the code
Add code
This section will focus on adding code to our Common, App, and Server packages.
Common
We’ll start with Common because this package will be used by app and Server. Its goal is to provide shared logic and variables.
file
In this tutorial, the Common package will be very simple. Start by adding a new folder:
src/
Folder containing the code for the package.
After creating this folder, add the following files to it:
src/index.ts
export const APP_TITLE = 'my-app';
Copy the code
Now that we have some code to export, we want to tell TypeScript where to look for it when importing it from other packages. To do this, we will need to update the package.json file:
package.json
{
"name": "@my-app/common"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."main": "./src/index.ts" // Add this line to provide an entry point for TS
}
Copy the code
We have now completed the Common package!
Structure reminder:
Common / ├─ SRC / │ ├─ index.package. JsonCopy the code
App
dependency
The app package will require the following dependencies:
- react
- react-dom
Run from the root of the project:
yarn app add react react-dom
yarn app add -D @types/react @types/react-dom
(forTypeScript
Add a typetypings
)
package.json
{
"name": "@my-app/app"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."dependencies": {
"@my-app/common": "^ 0.1.0 from".// Notice that we've added this import manually
"react": "^ 17.0.1"."react-dom": "^ 17.0.1"
},
"devDependencies": {
"@types/react": "^ 17.0.3"."@types/react-dom": "^ 17.0.2"}}Copy the code
file
To create our React application, we will need to add two new folders:
- a
public/
Folder, which will save the baseHTML
Page and oursassets
. - a
src/
Folder that contains the code for our application.
Once these two folders are created, we can start adding the HTML file that will host our application.
public/index.html
<! DOCTYPEhtml>
<html>
<head>
<title>my-app</title>
<meta name="description" content="Welcome on my application!" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<! -- This div is where we will inject the React application -->
<div id="root"></div>
<! -- This is the path to the script containing our application -->
<script src="script.js"></script>
</body>
</html>
Copy the code
Now that we have pages to render, we can implement a very basic but fully functional React application by adding the following two files.
src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { App } from './App';
ReactDOM.render(<App />.document.getElementById('root'));
Copy the code
This code hooks into the root div from our HTML file and injects the React component tree into it.
src/App.tsx
import { APP_TITLE } from '@flipcards/common';
import * as React from 'react';
export function App() :React.ReactElement {
const [count, setCount] = React.useState(0);
return (
<div>
<h1>Welcome on {APP_TITLE}!</h1>
<p>
This is the main page of our application where you can confirm that it
is dynamic by clicking the button below.
</p>
<p>Current count: {count}</p>
<button onClick={()= > setCount((prev) => prev + 1)}>Increment</button>
</div>
);
}
Copy the code
This simple App component will present our App title and dynamic counter. This will be our React Tree entry point. Feel free to add any code you want.
That’s it! We’ve completed the very basic React application. It doesn’t do much right now, but we can always use it later and add more features.
Structure reminder:
App / ├─ public/ │ ├─ index.html ├─ SRC / │ ├─ app.tsx │ ├─ package.jsonCopy the code
Server
dependency
The Server package will require the following dependencies:
- cors
- express
Run from the root of the project:
yarn server add cors express
yarn server add -D @types/cors @types/express
(forTypeScript
Add a typetypings
)
package.json
{
"name": "@my-app/server"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."dependencies": {
"@my-app/common": "^ 0.1.0 from".// Note that we have added this import manually
"cors": "^ 2.8.5"."express": "^ 4.17.1"
},
"devDependencies": {
"@types/cors": "^ 2.8.10"."@types/express": "^ 4.17.11"}}Copy the code
file
Now that our React application is ready, the final piece we need is the server to service it. First create the following folder for it:
- a
src/
Folder that contains the code for our server.
Next, add the server’s main file:
src/index.ts
import { APP_TITLE } from '@flipcards/common';
import cors from 'cors';
import express from 'express';
import { join } from 'path';
const PORT = 3000;
const app = express();
app.use(cors());
// Static resources from the "public" folder (e.g., when there are images to display)
app.use(express.static(join(__dirname, '.. /.. /app/public')));
// Serve HTML pages
app.get(The '*'.(req: any, res: any) = > {
res.sendFile(join(__dirname, '.. /.. /app/public'.'index.html'));
});
app.listen(PORT, () = > {
console.log(`${APP_TITLE}'s server listening at http://localhost:${PORT}`);
});
Copy the code
This is a very basic Express application, but if we don’t have any services other than a single-page application, that’s enough.
Structure reminder:
Server / ├─ SRC / │ ├─ ├─ Package.jsonCopy the code
Building an
Bundlers(Package build bundles)
To convert TypeScript code into interpretable JavaScript code and package all external libraries into a single file, we’ll use a packaging tool. There are many bundlers in the JS/TS ecosystem, such as WebPack, Parcel, or Rollup, but we will choose EsBuild. Compared to other bundlers, esBuild comes with many default loaded features (TypeScript, React) and a huge performance improvement (up to 100 times faster). If you’re interested in learning more, please take the time to read the author’s FAQ.
These scripts will require the following dependencies:
- Esbuild is our bundle
- ts-node 是
TypeScript
的REPL
, which we will use to execute the script
Run yarn add -d -w esbuild TS-node from the root directory of the project.
package.json
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
"esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
},
"scripts": {
"app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"}}Copy the code
Build
Now we have all the tools we need to build the application, so let’s create our first script.
Start by creating a new folder called scripts/ under the root of your project.
Our script will be written in TypeScript and executed from the command line using TS-Node. Although there is a CLI for esBuild, it is more convenient to use the library through JS or TS if you want to pass more complex parameters or combine multiple workflows.
Create a build.ts file in the scripts/ folder and add the code below (I’ll comment on what the code does) :
scripts/build.ts
import { build } from 'esbuild';
/** * Generic options passed during build. * /
interface BuildOptions {
env: 'production' | 'development';
}
/** * a constructor function for the app package. * /
export async function buildApp(options: BuildOptions) {
const { env } = options;
await build({
entryPoints: ['packages/app/src/index.tsx'].// We read the React application from this entry point
outfile: 'packages/app/public/script.js'.// We output a file in the public/ folder (remember that "script.js" is used in the HTML page)
define: {
'process.env.NODE_ENV': `"${env}"`.// We need to define the Node.js environment to build the application
},
bundle: true.minify: env === 'production'.sourcemap: env === 'development'}); }/** * Server package builder functionality. * /
export async function buildServer(options: BuildOptions) {
const { env } = options;
await build({
entryPoints: ['packages/server/src/index.ts'].outfile: 'packages/server/dist/index.js'.define: {
'process.env.NODE_ENV': `"${env}"`,},external: ['express'].// Some libraries must be marked as external
platform: 'node'.// When building Node, we need to set the environment for it
target: 'node14.15.5'.bundle: true.minify: env === 'production'.sourcemap: env === 'development'}); }/** * Builder functionality for all packages. * /
async function buildAll() {
await Promise.all([
buildApp({
env: 'production',
}),
buildServer({
env: 'production',})]); }This method is executed when we run the script from the terminal using TS-Node
buildAll();
Copy the code
The code is easy to interpret, but if you feel you’ve missed something, check out the ESBuild API documentation for a complete list of keywords.
Our build script is now complete! The last thing we need to do is add a new command to our package.json to make it easy to run the build operation.
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
"esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
},
"scripts": {
"app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts" // Add this line here}}Copy the code
You can now start the build process by running YARN Build from the root folder of your project every time you make a change to the project (how to add hot-reloading is discussed later).
Structure reminder:
My-app / ├─ Packages / ├─ scripts/ │ ├─ Build.ts ├─ package.json ├─ tsConfig.jsonCopy the code
Serve = Serve
With our application built and ready to be used around the world, we just need to add one last command to package.json:
{
"name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
"esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
},
"scripts": {
"app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts"."serve": "node ./packages/server/dist/index.js" // Add this line here}}Copy the code
Since we are now dealing with pure JavaScript, we can start the server using the Node binary. Therefore, continue to run YARN Serve.
If you look at the console, you will see that the server is listening successfully. You can also open a browser and navigate to http://localhost:3000 to display your React app 🎉!
If you want to change the PORT at run time, you can prefix the serve command with an environment variable: PORT=4000 YARN Serve.
Docker 🐳
This section assumes that you are already familiar with container concepts.
In order to be able to create an image based on our code, we need to have Docker installed on our computer. To learn how to install on an OS, take a moment to review the official documentation.
Dockerfile
To generate a Docker image, the first step is to create a Dockerfile in the root directory of our project (these steps can be done entirely through the CLI, but using a configuration file is the default way to define the build steps).
FROM node:14.15.5-alpine
WORKDIR /usr/src/app
# Install dependencies as early as possible, so that if our application
Docker does not need to download dependencies again because some files have changed.
# from the next step (" COPY.." To begin with.
COPY ./package.json .
COPY ./yarn.lock .
COPY ./packages/app/package.json ./packages/app/
COPY ./packages/common/package.json ./packages/common/
COPY ./packages/server/package.json ./packages/server/
RUN yarn
# copy all files of our application (except those specified in.gitignore)
COPY.
# compiler app
RUN yarn build
# Port
EXPOSE 3000
# Serve
CMD [ "yarn"."serve" ]
Copy the code
I’ll try to be as detailed as possible about what’s happening here and why the order of these steps is important:
FROM
tellDocker
Uses the specified underlying image for the current context. In our case, we want to have one that worksNode.js
The environment of the application.WORKDIR
Sets the current working directory in the container.COPY
Copies files or folders from the current local directory (the root of the project) to the working directory in the container. As you can see, in this step, we only copy the files associated with the dependencies. This is becauseDocker
Each result of each command in the build is cached as a layer. Because we want to optimize build time and bandwidth, we only want to reinstall dependencies when they change (which is usually less frequent than file changes).RUN
在shell
To execute the command.EXPOSE
Is used for the container’s internal port (with our applicationPORT env
Has nothing to do). Any value here should be good, but if you want to learn more, check it outThe official documentation.CMD
Is intended to provide default values for the execution container.
If you want to learn more about these keywords, check out the Dockerfile reference.
Add the dockerignore
Using the.dockerignore file is not mandatory, but the following files are strongly recommended:
- Make sure you are not copying garbage files into the container.
- make
COPY
Commands are easier to use.
If you’re already familiar with it, it works just like the.gitignore file. You can copy the following into a.dockerignore file at the same level as Dockerfile, which will be extracted automatically.
README.md
# Git
.gitignore
# Logs
yarn-debug.log
yarn-error.log
# Binaries
node_modules
*/*/node_modules
# Builds
*/*/build
*/*/dist
*/*/script.js
Copy the code
Feel free to add any files you want to ignore to lighten your final image.
Build the Docker Image
Now that our application is ready for Docker, we need a way to generate the actual image from Docker. To do this, we’ll add a new command to the root package.json:
{
"name": "my-app"."version": "1.0.0"."license": "MIT"."private": true."workspaces": ["packages/*"]."devDependencies": {
"esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
},
"scripts": {
"app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts"."serve": "node ./packages/server/dist/index.js"."docker": "docker build . -t my-app" // Add this line}}Copy the code
-t my-app tells docker to use the current directory (.) Find the Dockerfile and name the resulting image (-t) my-app.
Be sure to run the Docker daemon to use the Docker command on your terminal.
Now that the command is in the script for our project, you can run it using YARN Docker.
After running this command, you should expect to see the following terminal output:
Sending build context to Docker Daemon 76.16MB Step 1/12: FROM node:14.15.5-alpine --> c1babb15a629 Step 2/12: WORKDIR /usr/src/app ---> b593905aaca7 Step 3/12 : COPY ./package.json . ---> e0046408059c Step 4/12 : COPY ./yarn.lock . ---> a91db028a6f9 Step 5/12 : COPY ./packages/app/package.json ./packages/app/ ---> 6430ae95a2f8 Step 6/12 : COPY ./packages/common/package.json ./packages/common/ ---> 75edad061864 Step 7/12 : COPY ./packages/server/package.json ./packages/server/ ---> e8afa17a7645 Step 8/12 : RUN yarn ---> 2ca50e44a11a Step 9/12 : COPY . . ---> 0642049120cf Step 10/12 : RUN yarn build ---> Runningin15b224066078 YARN Run v1.22.5 $ts-node./scripts/build.ts Donein3.51 S. Removing intermediate Container 15B224066078 --> 9DCE2D505C62 Step 11/12: EXPOSE 3000 --> Runningin f363ce55486b
Removing intermediate container f363ce55486b
---> 961cd1512fcf
Step 12/12 : CMD [ "yarn"."serve" ]
---> Running in7debd7a72538 Removing intermediate container 7debd7a72538 ---> df3884d6b3d6 Successfully built df3884d6b3d6 Successfully tagged my-app:latestCopy the code
That’s it! Our image is now created and registered on your machine for Docker to use. If you want to list the available Docker images, you can run the Docker image ls command:
→ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
my-app latest df3884d6b3d6 4 minutes ago 360MB
Copy the code
Run the command like this
Running a working Docker image from the command line is very simple: Docker run -d -p 318:3000 my-app
-d
Run the container in split mode (in the background).-p
Sets the port to expose the container in the format[host port]:[container port]
). So if we want to add ports inside the container3000
(rememberDockerfile
In theEXPOSE
Port exposed to the outside of the container8000
, we will putA 8000-3000
Passed to the-p
Mark.
You can confirm that your container is running Docker PS. This will list all running containers:
If you have additional requirements and questions about starting containers, find more information here.
→ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
71465a89b58b my-app "Docker - entrypoint. S..."7 seconds ago Up 6 seconds 0.0.0.0:3000->3000/ TCP Determined_shockleyCopy the code
Now, open your browser and navigate to the following URL http://localhost:3000 to see the application you are running 🚀!