Building a Simple Code Runner with TypeScript, Docker, React, and Express
In this project, we are going to build a simple code runner that will be able to run Node.js, Golang, and Python code. We will use Express for the backend and React for the frontend.
Preparing the Environment to Run Code
We are going to use a Docker container for both our code-running environment and our backend API. Here’s what our backend Dockerfile
looks like:
FROM alpine:latest
WORKDIR /app
RUN apk add python3
RUN apk add --no-cache git make musl-dev go curl bash
RUN apk add --no-cache nodejs npm
COPY ./backend/package*.json ./
RUN npm install
COPY --chown=node:node ./backend .
EXPOSE 3000
CMD [ "npm","run", "dev" ]
We use Alpine as our base image and install Python3, Golang, and Node.js using the RUN
command. We then copy the package.json
of our backend to the image and install dependencies.
Backend
We are going to use Express for the backend API. There will be a route /run-code
that expects the language and code text in the request body. We will use Joi to validate the request body:
const RunCodeRequestValidator = Joi.object()
.keys({
lang: Joi.string()
.valid('python', 'node', 'golang', 'typescript')
.required(),
code: Joi.string().allow('')
});
In the router file:
this.router.post(`${this.path}/run-code`,
JoiValidator(RunCodeRequestValidator),
this.controller.runCode
);
After receiving the request, we create a temporary file to store the code. After running the file and getting the output, we remove the file. For file-related tasks, we create a FileManagerService:
import fs from 'fs/promises';
import { v4 as uuid } from 'uuid';
class FileManagerService {
public async create(lang, code): Promise<{ success: boolean; path: string }> {
try {
const extensionMap = {
'python': 'py',
'node': 'js',
'golang': 'go'
};
const path = `./files/${uuid()}.${extensionMap[lang]}`;
await fs.writeFile(path, code);
return { success: true, path };
} catch (error) {
return { success: false, path: null };
}
}
public async remove(path) {
try {
await fs.unlink(path);
return { success: true };
} catch (error) {
console.log(error);
return { success: false };
}
}
}
The FileManagerService
has two methods: create
and remove
. The create
method generates file names with uuid
and assigns extensions based on the language type. The remove
method deletes the file using fs.unlink()
.
We will have a single class for every language, all implementing the RunnerInterface
with a run
method:
interface RunnerInterface {
run(path): Promise<string>;
}
For example, here’s the PythonRunnerService:
import { exec } from 'child_process';
import RunnerInterface from '../../interfaces/runner.interface';
class PythonRunnerService implements RunnerInterface {
public async run(path): Promise<string> {
try {
const res1 = await (new Promise((resolve, reject) => {
exec(`python3 ${path}`, (error, stdout, stderr) => {
if (error) reject(error);
if (stderr) reject(stderr);
resolve(stdout);
});
}));
return res1 as string;
} catch (error) {
console.log(error);
return error.toString();
}
}
}
Similarly, for Node.js:
class NodeRunnerService implements RunnerInterface {
public async run(path): Promise<string> {
try {
const res1 = await (new Promise((resolve, reject) => {
exec(`node ${path}`, (error, stdout, stderr) => {
if (error) reject(error);
if (stderr) reject(stderr);
resolve(stdout);
});
}));
return res1 as string;
} catch (error) {
console.log(error);
return error.toString();
}
}
}
And for Golang:
class GoLangRunnerService implements RunnerInterface {
async run(path: any): Promise<string> {
try {
const res1 = await (new Promise((resolve, reject) => {
exec(`go run ${path}`, (error, stdout, stderr) => {
if (error) reject(error);
if (stderr) reject(stderr);
resolve(stdout);
});
}));
return res1 as string;
} catch (error) {
console.log(error);
return error.toString();
}
}
}
We map these language-related services from a Manager, which is used in the controller:
class Manager {
public static async getOutput(lang, code): Promise<string> {
const fileManagerService = new FileManagerService();
const { success, path } = await fileManagerService.create(lang, code);
if (!success) return 'error';
const mp = {
'python': PythonRunnerService,
'node': NodeRunnerService,
'golang': GoLangRunnerService
};
const runner: RunnerInterface = new mp[lang]();
const out = await runner.run(path);
await fileManagerService.remove(path);
return out;
}
}
Docker Compose
To bring the whole thing up with a single command, we can use Docker Compose. We also have a Dockerfile
for our frontend project:
FROM node:latest
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY package*.json ./
USER node
COPY --chown=node:node . .
EXPOSE 3001
CMD [ "yarn", "dev" ]
The project structure will look like this:
Here’s the docker-compose.yml
file:
services:
server:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- 3000:3000
volumes:
- ./backend:/app
client:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- 3001:3001
volumes:
- ./frontend:/home/node/app
- ./frontend/node_modules:/home/node/app/node_modules
We have two services: server
and client
. Bind mounts are used to mount code from the local drive to the container, which is helpful for development. If we run docker-compose up
, we can access the frontend at localhost:3001
.
GitHub Repository
Here’s the GitHub repository link to the project:
https://github.com/aistiak/code-runner