62 | Welcome to the a demo of React Server Components running on Azure Static Web Apps.
63 | See the GitHub repo to learn more.
64 |
65 |
66 |
67 | To view and edit notes, log in with one of these providers:
68 |
69 |
70 |
71 | }
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/function_react/index.server.js:
--------------------------------------------------------------------------------
1 | require('../funcutil/babelregister.server');
2 |
3 | const React = require('react');
4 | const ReactApp = require('../src/App.server').default;
5 | const { Pool } = require('pg');
6 | const pool = new Pool(require('../credentials'));
7 |
8 | const { createResponseBody } = require('../funcutil/react-utils.server');
9 |
10 | const { decodeAuthInfo } = require('../funcutil/auth');
11 |
12 | async function reactFunction(context, req) {
13 | return await createResponse(context, req);
14 | }
15 |
16 | async function notesPutFunction(context, req) {
17 | const userInfo = decodeAuthInfo(req);
18 | if (!userInfo) {
19 | return { status: 401 };
20 | }
21 |
22 | const now = new Date();
23 | const updatedId = Number(req.params.id);
24 | await pool.query(
25 | 'update notes set title = $1, body = $2, updated_at = $3 where id = $4 and userid = $5',
26 | [req.body.title, req.body.body, now, updatedId, userInfo.userId]
27 | );
28 | return await createResponse(context, req);
29 | }
30 |
31 | async function notesPostFunction(context, req) {
32 | const userInfo = decodeAuthInfo(req);
33 | if (!userInfo) {
34 | return { status: 401 };
35 | }
36 |
37 | const now = new Date();
38 | const result = await pool.query(
39 | 'insert into notes (title, body, created_at, updated_at, userid) values ($1, $2, $3, $3, $4) returning id',
40 | [req.body.title, req.body.body, now, userInfo.userId]
41 | );
42 | const insertedId = result.rows[0].id;
43 | return await createResponse(context, req, insertedId);
44 | }
45 |
46 | async function notesDeleteFunction(context, req) {
47 | const userInfo = decodeAuthInfo(req);
48 | if (!userInfo) {
49 | return { status: 401 };
50 | }
51 |
52 | await pool.query(
53 | 'delete from notes where id = $1 and userid = $2',
54 | [req.params.id, userInfo.userId]);
55 | return await createResponse(context, req);
56 | }
57 |
58 | async function createResponse(context, req, redirectToId) {
59 | const location = JSON.parse(req.query.location);
60 |
61 | if (redirectToId) {
62 | location.selectedId = redirectToId;
63 | }
64 |
65 | const userInfo = decodeAuthInfo(req);
66 |
67 | const props = {
68 | userInfo,
69 | selectedId: location.selectedId,
70 | isEditing: location.isEditing,
71 | searchText: location.searchText,
72 | };
73 |
74 | if (userInfo) {
75 | context.log(JSON.stringify(userInfo, null, 2));
76 | }
77 |
78 | const responseBody = await createResponseBody(React.createElement(ReactApp, props));
79 | return {
80 | body: responseBody,
81 | headers: {
82 | 'X-Location': JSON.stringify(location),
83 | 'Access-Control-Expose-Headers': 'X-Location'
84 | }
85 | };
86 | }
87 |
88 | module.exports = {
89 | reactFunction,
90 | notesDeleteFunction,
91 | notesPostFunction,
92 | notesPutFunction,
93 | };
--------------------------------------------------------------------------------
/scripts/seed.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | 'use strict';
10 |
11 | const fs = require('fs');
12 | const path = require('path');
13 | const {Pool} = require('pg');
14 | const {readdir, unlink, writeFile} = require('fs/promises');
15 | const startOfYear = require('date-fns/startOfYear');
16 | const credentials = require('../credentials');
17 |
18 | const NOTES_PATH = './notes';
19 | const pool = new Pool(credentials);
20 |
21 | const now = new Date();
22 | const startOfThisYear = startOfYear(now);
23 | // Thanks, https://stackoverflow.com/a/9035732
24 | function randomDateBetween(start, end) {
25 | return new Date(
26 | start.getTime() + Math.random() * (end.getTime() - start.getTime())
27 | );
28 | }
29 |
30 | const dropTableStatement = 'DROP TABLE IF EXISTS notes;';
31 | const createTableStatement = `CREATE TABLE notes (
32 | id SERIAL PRIMARY KEY,
33 | created_at TIMESTAMP NOT NULL,
34 | updated_at TIMESTAMP NOT NULL,
35 | title TEXT,
36 | userid TEXT,
37 | body TEXT
38 | );`;
39 | const insertNoteStatement = `INSERT INTO notes(title, body, created_at, updated_at)
40 | VALUES ($1, $2, $3, $3)
41 | RETURNING *`;
42 | const seedData = [
43 | [
44 | 'Meeting Notes',
45 | 'This is an example note. It contains **Markdown**!',
46 | randomDateBetween(startOfThisYear, now),
47 | ],
48 | [
49 | 'Make a thing',
50 | `It's very easy to make some words **bold** and other words *italic* with
51 | Markdown. You can even [link to React's website!](https://www.reactjs.org).`,
52 | randomDateBetween(startOfThisYear, now),
53 | ],
54 | [
55 | 'A note with a very long title because sometimes you need more words',
56 | `You can write all kinds of [amazing](https://en.wikipedia.org/wiki/The_Amazing)
57 | notes in this app! These note live on the server in the \`notes\` folder.
58 |
59 | `,
60 | randomDateBetween(startOfThisYear, now),
61 | ],
62 | ['I wrote this note today', 'It was an excellent note.', now],
63 | ];
64 |
65 | async function seed() {
66 | await pool.query(dropTableStatement);
67 | await pool.query(createTableStatement);
68 | const res = await Promise.all(
69 | seedData.map((row) => pool.query(insertNoteStatement, row))
70 | );
71 | console.log(res);
72 |
73 | // const oldNotes = await readdir(path.resolve(NOTES_PATH));
74 | // await Promise.all(
75 | // oldNotes
76 | // .filter((filename) => filename.endsWith('.md'))
77 | // .map((filename) => unlink(path.resolve(NOTES_PATH, filename)))
78 | // );
79 |
80 | // await Promise.all(
81 | // res.map(({rows}) => {
82 | // const id = rows[0].id;
83 | // const content = rows[0].body;
84 | // const data = new Uint8Array(Buffer.from(content));
85 | // return writeFile(path.resolve(NOTES_PATH, `${id}.md`), data, (err) => {
86 | // if (err) {
87 | // throw err;
88 | // }
89 | // });
90 | // })
91 | // );
92 | }
93 |
94 | seed();
95 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | 'use strict';
10 |
11 | const path = require('path');
12 | const rimraf = require('rimraf');
13 | const webpack = require('webpack');
14 | const HtmlWebpackPlugin = require('html-webpack-plugin');
15 | const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin');
16 | const FileManagerPlugin = require('filemanager-webpack-plugin');
17 |
18 | const isProduction = process.env.NODE_ENV === 'production';
19 | rimraf.sync(path.resolve(__dirname, '../build'));
20 | webpack(
21 | {
22 | mode: isProduction ? 'production' : 'development',
23 | devtool: isProduction ? 'source-map' : 'cheap-module-source-map',
24 | entry: [path.resolve(__dirname, '../src/index.client.js')],
25 | output: {
26 | path: path.resolve(__dirname, '../build'),
27 | filename: 'main.js',
28 | },
29 | module: {
30 | rules: [
31 | {
32 | test: /\.js$/,
33 | use: 'babel-loader',
34 | exclude: /node_modules/,
35 | },
36 | ],
37 | },
38 | plugins: [
39 | new HtmlWebpackPlugin({
40 | inject: true,
41 | template: path.resolve(__dirname, '../public/index.html'),
42 | }),
43 | new ReactServerWebpackPlugin({ isServer: false }),
44 | new FileManagerPlugin({
45 | events: {
46 | // copy public folder over to build, but keep what's already in build
47 | onEnd: [
48 | {
49 | mkdir: [path.resolve(__dirname, '../buildtemp')],
50 | },
51 | {
52 | copy: [
53 | {
54 | source: path.resolve(__dirname, '../public'),
55 | destination: path.resolve(__dirname, '../buildtemp')
56 | },
57 | ]
58 | },
59 | {
60 | copy: [
61 | {
62 | source: path.resolve(__dirname, '../build'),
63 | destination: path.resolve(__dirname, '../buildtemp')
64 | },
65 | ]
66 | },
67 | {
68 | copy: [
69 | {
70 | source: path.resolve(__dirname, '../buildtemp'),
71 | destination: path.resolve(__dirname, '../build')
72 | },
73 | ]
74 | },
75 | {
76 | delete: [path.resolve(__dirname, '../buildtemp')]
77 | },
78 | ],
79 | },
80 | }),
81 | ],
82 | },
83 | (err, stats) => {
84 | if (err) {
85 | console.error(err.stack || err);
86 | if (err.details) {
87 | console.error(err.details);
88 | }
89 | process.exit(1);
90 | return;
91 | }
92 | const info = stats.toJson();
93 | if (stats.hasErrors()) {
94 | console.log('Finished running webpack with errors.');
95 | info.errors.forEach((e) => console.error(e));
96 | process.exit(1);
97 | } else {
98 | console.log('Finished running webpack.');
99 | }
100 | }
101 | );
102 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at . All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/src/NoteEditor.client.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {useState, unstable_useTransition} from 'react';
10 | import {createFromReadableStream} from 'react-server-dom-webpack';
11 | import {apiBaseUrl} from './config';
12 |
13 | import NotePreview from './NotePreview';
14 | import {useRefresh} from './Cache.client';
15 | import {useLocation} from './LocationContext.client';
16 |
17 | export default function NoteEditor({noteId, initialTitle, initialBody}) {
18 | const refresh = useRefresh();
19 | const [title, setTitle] = useState(initialTitle);
20 | const [body, setBody] = useState(initialBody);
21 | const [location, setLocation] = useLocation();
22 | const [startNavigating, isNavigating] = unstable_useTransition();
23 | const [isSaving, saveNote] = useMutation({
24 | endpoint: noteId !== null ? `${apiBaseUrl}/notes/${noteId}` : `${apiBaseUrl}/notes`,
25 | method: noteId !== null ? 'PUT' : 'POST',
26 | });
27 | const [isDeleting, deleteNote] = useMutation({
28 | endpoint: `${apiBaseUrl}/notes/${noteId}`,
29 | method: 'DELETE',
30 | });
31 |
32 | async function handleSave() {
33 | const payload = {title, body};
34 | const requestedLocation = {
35 | selectedId: noteId,
36 | isEditing: false,
37 | searchText: location.searchText,
38 | };
39 | const response = await saveNote(payload, requestedLocation);
40 | navigate(response);
41 | }
42 |
43 | async function handleDelete() {
44 | const payload = {};
45 | const requestedLocation = {
46 | selectedId: null,
47 | isEditing: false,
48 | searchText: location.searchText,
49 | };
50 | const response = await deleteNote(payload, requestedLocation);
51 | navigate(response);
52 | }
53 |
54 | function navigate(response) {
55 | let cacheKey = response.headers.get('X-Location');
56 | let nextLocation = JSON.parse(cacheKey);
57 | const seededResponse = createFromReadableStream(response.body);
58 | startNavigating(() => {
59 | refresh(cacheKey, seededResponse);
60 | setLocation(nextLocation);
61 | });
62 | }
63 |
64 | const isDraft = noteId === null;
65 | return (
66 |
67 |
93 |
94 |
95 |
109 | {!isDraft && (
110 |
124 | )}
125 |
126 |
127 | Preview
128 |
129 |
{title}
130 |
131 |
132 |
133 | );
134 | }
135 |
136 | function useMutation({endpoint, method}) {
137 | const [isSaving, setIsSaving] = useState(false);
138 | const [didError, setDidError] = useState(false);
139 | const [error, setError] = useState(null);
140 | if (didError) {
141 | // Let the nearest error boundary handle errors while saving.
142 | throw error;
143 | }
144 |
145 | async function performMutation(payload, requestedLocation) {
146 | setIsSaving(true);
147 | try {
148 | const response = await fetch(
149 | `${endpoint}?location=${encodeURIComponent(
150 | JSON.stringify(requestedLocation)
151 | )}`,
152 | {
153 | method,
154 | body: JSON.stringify(payload),
155 | headers: {
156 | 'Content-Type': 'application/json',
157 | },
158 | }
159 | );
160 | if (!response.ok) {
161 | throw new Error(await response.text());
162 | }
163 | return response;
164 | } catch (e) {
165 | setDidError(true);
166 | setError(e);
167 | } finally {
168 | setIsSaving(false);
169 | }
170 | }
171 |
172 | return [isSaving, performMutation];
173 | }
174 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Server Components Demo - Azure Static Web Apps
2 |
3 | This is a port of the [React Server Components "React Notes"](https://github.com/reactjs/server-components-demo) demo for Azure Static Web Apps. React Server Components is currently an experimental project, and the code here to make it work in Azure is equally experimental. For demonstration purposes only!
4 |
5 | **Live demo: https://react-notes.anthonychu.com/**
6 |
7 | Azure services used:
8 | - [Azure Static Web Apps](https://docs.microsoft.com/en-us/azure/static-web-apps/overview) (and Azure Functions)
9 | - Azure Database for PostgreSQL - Flexible Server
10 |
11 | See [original README](README.orig.md) for license and other info.
12 |
13 | ## Run locally
14 |
15 | 1. Fork and clone this repo.
16 |
17 | 1. Start an instance of Postgres locally with the demo's default credentials. Docker works great:
18 | ```bash
19 | docker run --name react-notes -p 5432:5432 -e POSTGRES_USER=notesadmin -e POSTGRES_PASSWORD=password -d postgres
20 | ```
21 |
22 | 1. Install Azure Functions Core Tools.
23 | ```bash
24 | npm i -g azure-functions-core-tools@3 --unsafe-perm true
25 | ```
26 |
27 | 1. Update `src/config.js` to use local Azure Functions URL:
28 | ```js
29 | module.exports = {
30 | apiBaseUrl: '/api'
31 | };
32 | ```
33 |
34 | 1. Build the app.
35 | ```bash
36 | npm install
37 | npm run build
38 | ```
39 |
40 | 1. Start the Azure Functions app.
41 | ```bash
42 | func start
43 | ```
44 |
45 | 1. Serve the frontend with a web server. Using Python here but anything works.
46 | ```bash
47 | python -m http.server
48 | ```
49 |
50 | ## Deploy to Azure
51 |
52 | 1. Create a Postgres Database in Azure
53 | - [Azure Database for PostgreSQL - Flexible Server](https://docs.microsoft.com/en-us/azure/postgresql/flexible-server/quickstart-create-server-portal) recommended
54 | - Cheapest one works great
55 |
56 | 1. Seed database
57 | - Set `DB_HOST`, `DB_USER`, and `DB_PASSWORD` environment variables to match how you configured your Azure Postgres instance
58 | - Run `npm run seed`
59 |
60 | 1. Create an Azure Static Web App
61 | - App location: `build`
62 | - API location: `/`
63 | - Artifact (output) location: (leave blank)
64 |
65 | 1. The workflow needs to be modified to build the app properly. Add an Action to the generated workflow:
66 | ```yaml
67 | - name: Build app and API
68 | run: | # build and then remove package.json (so deploy step doesn't reinstall modules)
69 | npm install
70 | npm run build
71 | npm prune --production
72 | rm package.json package-lock.json
73 | ls -la build
74 | ```
75 | Save and push the file to trigger another deployment. See [this file](.github/workflows/azure-static-web-apps-kind-wave-0f8b93b1e.yaml) for an example.
76 |
77 | 1. In the Azure portal, go to the Static Web App and open *Configuration*. Enter the following settings:
78 | | name | value |
79 | | --- | --- |
80 | | `BABEL_DISABLE_CACHE` | `1` |
81 | | `DB_HOST` | `.postgres.database.azure.com` |
82 | | `DB_USER` | your database username |
83 | | `DB_PASSWORD` | your database password |
84 | | `DB_SSL` | `1` |
85 | | `languageWorkers__node__arguments` | `--conditions=react-server` |
86 | | `NODE_ENV` | `production` |
87 |
88 | 1. Save the settings. It may take a few seconds to take effect. If all goes well, go to the app's URL and you should see the app.
89 |
90 | ## How does this work?
91 |
92 | 🚨 While it is fully functional, this is entirely experimental and for demonstration purposes only. Do not use for anything resembling production.
93 |
94 | A few changes were made to the demo app to work better in Azure.
95 |
96 | - Some React Server Components originally called a local HTTP endpoint. When it's running in Azure Functions, it's not advisible to make HTTP calls to itself. Those calls were converted to direct Postgres queries.
97 | - Some changes to the WebPack fonfig `scripts/build.js` to combine the contents of `build` and `publish` folders.
98 | - Changed Postgres config to enable SSL when calling an Azure database.
99 |
100 | ### Changes for Azure Functions / Static Web Apps
101 |
102 | - An HTTP function was created for every endpoint in the original demo. The functions themselves are all in `function_react/index.server.js`.
103 | - The function filename must end in `.server.js` to satisfy React Server Components conventions.
104 | - While Azure Functions supports Node.js 14, the version of Node.js currently available in Static Web Apps is 12. The demo app uses `fs/promises`, which is only in Node.js 14. Added a shim at `fs/promises.js` to get around this.
105 | - The demo requires the `--conditions` flag to be set in the Node.js process. This flag is set with `languageWorkers__node__arguments` app setting. Because Azure Functions starts the Node worker process before your app is loaded, you typically need to set an extra app setting (`WEBSITE_USE_PLACEHOLDER=0`) to delay the start of the worker process. However, function apps in Static Web Apps are not allowed to configure app settings starting with `WEBSITE_`. To get around this, if the `conditions` flag isn't set, there is code in `funcutil/babelregister.server.js` to cause a restart in the Node process. This is a huge hack and should never be used in a production app!
106 | - The `pipeToNodeWritable` function in React Server Components requires writing to a stream. Like some other serverless platforms, Azure Functions is unable to stream responses. We use a `memory-stream` for this.
107 | - `pipeToNodeWritable` looks up client components in a generated manifest. Because the manifest contains full paths from the build machine that are different than the paths in the Azure Functions environment, we use a proxy to select the file with the nearest matching name. See `funcutil/react-utils.server.js`.
108 | - CORS - When running locally and the frontend is served from a different port than the Azure Functions app, CORS is required. CORS is enabled on Azure Functions, but because the demo relies on an `X-Location` header, an additional `'Access-Control-Expose-Headers': 'X-Location'` must be added to responses.
109 | - Authentication was added to the app to allow only logged in users to view and modify their own notes.
--------------------------------------------------------------------------------
/README.orig.md:
--------------------------------------------------------------------------------
1 | # React Server Components Demo
2 |
3 | * [What is this?](#what-is-this)
4 | * [When will I be able to use this?](#when-will-i-be-able-to-use-this)
5 | * [Setup](#setup)
6 | * [DB Setup](#db-setup)
7 | + [Step 1. Create the Database](#step-1-create-the-database)
8 | + [Step 2. Connect to the Database](#step-2-connect-to-the-database)
9 | + [Step 3. Run the seed script](#step-3-run-the-seed-script)
10 | * [Notes about this app](#notes-about-this-app)
11 | + [Interesting things to try](#interesting-things-to-try)
12 | * [Built by (A-Z)](#built-by-a-z)
13 | * [Code of Conduct](#code-of-conduct)
14 | * [License](#license)
15 |
16 | ## What is this?
17 |
18 | This is a demo app built with Server Components, an experimental React feature. **We strongly recommend [watching our talk introducing Server Components](https://reactjs.org/server-components) before exploring this demo.** The talk includes a walkthrough of the demo code and highlights key points of how Server Components work and what features they provide.
19 |
20 | ## When will I be able to use this?
21 |
22 | Server Components are an experimental feature and **are not ready for adoption**. For now, we recommend experimenting with Server Components via this demo app. **Use this in your projects at your own risk.**
23 |
24 | ## Setup
25 |
26 | You will need to have nodejs >=14.9.0 in order to run this demo. [Node 14 LTS](https://nodejs.org/en/about/releases/) is a good choice!
27 |
28 | ```
29 | npm install
30 | npm start
31 | ```
32 |
33 | (Or `npm run start:prod` for a production build.)
34 |
35 | Then open http://localhost:4000.
36 |
37 | The app won't work until you set up the database, as described below.
38 |
39 |
40 | Setup with Docker
41 |
You can also start dev build of the app by using docker-compose.
42 |
Make sure you have docker and docker-compose installed then run: