Table of contents
In this project, we are going to build a simple code runner that will be able to run nodejs , golang and python code
we are going to use Express for backend and React for frontend
Preparing environment to run code
we are going to use a docker container for both our code running environment and for our backend API , this is what our backend Dockerfile would look 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 ./
# USER node
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 nodejs
latest version with RUN
command. And copy the package.json of our backend to the image and install dependencies as we are also going to put our backend inside this container
Backend
we are going to use express for backend API . There will be a route /run-code
it would expect the language and code text in the request body, Joi will be used 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 are going to create a temporary file to store the code and after running the file and getting the output we are going to remove the file So for file-related tasks we are going to create a file manager service
import fs from 'fs/promises'
import { v4 as uuid } from 'uuid';
class FileManagerService {
constructor() {
}
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 }
}
}
}
FileManagerService
has two methods create
and remove
. On create method, we generate the file names with uuid
and give them extensions based on the language type and store the files in /files
directory of the project. The method returns a promise which resolves to an object which has success
and path
fields, if file creation is successful success
is true else false. And on the remove method, we simply remove the file with fs.unlink()
We will have a single class for every language which will all implement RunnerInterface
with a run
method inside. Every language class will have to implement its logic to run code related to it.
interface RunnerInterface {
run(path) : Promise<string>
}
In this way In the future when we decide to add more languages to our app we won't need to write code to a single file, every language would have a class associated with it , for Python there will be a 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()
}
}
}
hear we use the exec
method from nodejs built-in module child_process
to run the code and collect the output, the run
method takes in path
as a parameter which we get from FileManagerService.create
method. To run the Python code we need to pass python3 python/file/path
to exec and collect the output, we resolve the output if no error and reject with an error object if there is any error
Similarly for nodejs
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)
})
}))
// console.log({ res1 })
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()
}
}
}
and 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
}
}
we now just need to get the language with a dropdown and code from a textarea and pass them down to the API when calling and show the response in another textarea and we have ourselves a code runner
Docker compose
To bring the whole thing up with a single command we can use docker-compose we would also have a docker file 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
# RUN yarn install
COPY --chown=node:node . .
EXPOSE 3001
CMD [ "yarn", "dev" ]
so the project structure would look like this
now to combine the backend and frontend we write a docker-compose 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
hear we have two services server and client. Bind mount was used here to mount code from the local drive to the container which is of great help for development as we could run both the backend and frontend with a single command and see code changes made to our local drive reflected inside the container. If we run docker-compose up we can access the client / frontend at localhost:3001 in our browse.
here is the GitHub repository link to the project