Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/backend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/bun": "latest",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",
Expand Down
154 changes: 154 additions & 0 deletions apps/backend/src/controllers/compilerController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Router, type Request, type Response } from 'express';
import { setupProject, createRustProject } from '../utils/fileManager';
import { executeCommand } from '../utils/commandExecutor';

const router = Router();

/**
* Request body interface for compile and test endpoints
*/
interface CompilerRequest {
code: string;
}

/**
* Response interface for successful operations
*/
interface CompilerResponse {
output: string;
}

/**
* Error response interface
*/
interface ErrorResponse {
error: string;
message: string;
}

/**
* Validates the request body to ensure code is a non-empty string
*/
function validateRequest(req: Request): string | null {
const { code } = req.body as CompilerRequest;

if (!code || typeof code !== 'string') {
return 'Code must be provided as a non-empty string';
}

if (code.trim() === '') {
return 'Code cannot be empty';
}

return null;
}

/**
* POST /api/compile
* Compiles Rust/Soroban code using cargo build and stellar contract build
*/
router.post('/compile', async (req: Request, res: Response<CompilerResponse | ErrorResponse>) => {
try {
// Validate request
const validationError = validateRequest(req);
if (validationError) {
return res.status(400).json({
error: 'Invalid input',
message: validationError,
});
}

const { code } = req.body as CompilerRequest;

// Set up temporary project
const project = await setupProject({ baseName: 'compile-project' });

try {
// Create Rust project structure
await createRustProject(project.tempDir, code);

// Execute compilation commands
const buildCommand = `cargo build --target wasm32-unknown-unknown --release`;
await executeCommand(buildCommand, 300000, project.tempDir);

// Execute stellar contract build using full path
const stellarCommand = `stellar contract build`;
const output = await executeCommand(stellarCommand, 300000, project.tempDir);

// Return successful compilation result
res.status(200).json({
output: output || 'Compilation successful',
});
} catch (error) {
// Handle compilation errors
const errorMessage = error instanceof Error ? error.message : 'Unknown compilation error';
res.status(400).json({
error: 'Compilation failed',
message: errorMessage,
});
} finally {
// Always cleanup the temporary directory
await project.cleanup();
}
} catch {
// Handle server errors
res.status(500).json({
error: 'Internal server error',
message: 'Failed to process compilation request',
});
}
});

/**
* POST /api/test
* Runs tests for Rust/Soroban code using cargo test
*/
router.post('/test', async (req: Request, res: Response<CompilerResponse | ErrorResponse>) => {
try {
// Validate request
const validationError = validateRequest(req);
if (validationError) {
return res.status(400).json({
error: 'Invalid input',
message: validationError,
});
}

const { code } = req.body as CompilerRequest;

// Set up temporary project
const project = await setupProject({ baseName: 'test-project' });

try {
// Create Rust project structure
await createRustProject(project.tempDir, code);

// Execute test command
const testCommand = `cargo test`;
const output = await executeCommand(testCommand, 300000, project.tempDir);

// Return successful test result
res.status(200).json({
output: output || 'All tests passed',
});
} catch (error) {
// Handle test errors
const errorMessage = error instanceof Error ? error.message : 'Unknown test error';
res.status(400).json({
error: 'Tests failed',
message: errorMessage,
});
} finally {
// Always cleanup the temporary directory
await project.cleanup();
}
} catch {
// Handle server errors
res.status(500).json({
error: 'Internal server error',
message: 'Failed to process test request',
});
}
});

export default router;
8 changes: 6 additions & 2 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import express from 'express';
import express, { type Request, type Response, type NextFunction } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { setupProject, getSanitizedDirName, createRustProject } from './utils/fileManager';
import compilerController from './controllers/compilerController';

const app = express();

Expand Down Expand Up @@ -45,6 +46,9 @@ app.get('/', (_, res) =>
res.send('Hello from Backend!' + '<br>' + 'The best online soroban compiler is coming...')
);

// Mount compiler controller at /api
app.use('/api', compilerController);

// Test endpoint for fileManager functionality
app.post('/api/test-filemanager', async (req, res) => {
try {
Expand Down Expand Up @@ -83,7 +87,7 @@ app.post('/api/test-filemanager', async (req, res) => {
});

// Error handling middleware
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong',
Expand Down
21 changes: 16 additions & 5 deletions apps/backend/src/utils/commandExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@ import { spawn, type SpawnOptionsWithoutStdio } from 'child_process';

const DEFAULT_TIMEOUT = 30000;

interface CommandError extends Error {
stderr: string;
stdout: string;
code: number | null;
}

/**
* Executes a shell command securely with a timeout
* @param command The command to execute
* @param timeout Maximum execution time in milliseconds (default: 30000)
* @param cwd Working directory to execute the command in (optional)
* @returns Promise that resolves with the command output
* @throws Error with stderr content if command fails or times out
*/
export async function executeCommand(
command: string,
timeout: number = DEFAULT_TIMEOUT
timeout: number = DEFAULT_TIMEOUT,
cwd?: string
): Promise<string> {
// Validate inputs
if (typeof command !== 'string' || command.trim() === '') {
Expand All @@ -25,6 +33,7 @@ export async function executeCommand(
const options: SpawnOptionsWithoutStdio = {
shell: '/bin/bash',
env: { ...process.env },
...(cwd && { cwd }),
};

return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -56,10 +65,12 @@ export async function executeCommand(
if (code === 0) {
resolve(stdout.trim());
} else {
const error = new Error(stderr.trim() || `Command failed with exit code ${code}`);
(error as any).stderr = stderr.trim();
(error as any).stdout = stdout.trim();
(error as any).code = code;
const error = new Error(
stderr.trim() || `Command failed with exit code ${code}`
) as CommandError;
error.stderr = stderr.trim();
error.stdout = stdout.trim();
error.code = code;
reject(error);
}
});
Expand Down