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.


Code Runner Preview

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:

Project Structure

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