Skip to content

Commit ad78de6

Browse files
committed
feat: add Piston API fallback for code execution without Docker
When Docker is unavailable (e.g., Render free tier), code execution falls back to the Piston API (emkc.org) which supports JS, Python, C, C++, Java, Rust, and TypeScript in isolated containers on their servers. No user code runs on our server = secure.
1 parent 5cd8285 commit ad78de6

1 file changed

Lines changed: 107 additions & 5 deletions

File tree

services/api-node/src/sandbox.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { spawn } from "node:child_process";
2+
import { readFile } from "node:fs/promises";
3+
import path from "node:path";
24
import { assertCommandAllowed, workspacePath } from "./security.js";
35

46
const dockerImages: Record<string, string> = {
@@ -29,10 +31,8 @@ export async function runInDocker(workspaceId: string, language: string, command
2931
assertCommandAllowed(command);
3032
const available = await dockerAvailable();
3133
if (!available) {
32-
return {
33-
ok: false,
34-
output: "Docker is not installed or not on PATH. Install Docker Desktop to enable isolated sandboxes."
35-
};
34+
// Fallback: use Piston API for code execution when Docker is unavailable
35+
return runViaPiston(workspaceId, language, command);
3636
}
3737

3838
const cwd = workspacePath(workspaceId);
@@ -76,7 +76,7 @@ export async function imageStatus() {
7676
const uniqueImages = [...new Set(Object.values(dockerImages))];
7777
const available = await dockerAvailable();
7878
if (!available) {
79-
return uniqueImages.map((image) => ({ image, present: false, reason: "Docker unavailable" }));
79+
return uniqueImages.map((image) => ({ image, present: false, reason: "Docker unavailable (using Piston API)" }));
8080
}
8181

8282
return Promise.all(
@@ -90,3 +90,105 @@ export async function imageStatus() {
9090
)
9191
);
9292
}
93+
94+
// ── Piston API fallback for environments without Docker ───────────
95+
const PISTON_URL = "https://emkc.org/api/v2/piston/execute";
96+
97+
const pistonLangMap: Record<string, { language: string; version: string }> = {
98+
javascript: { language: "javascript", version: "18.15.0" },
99+
node: { language: "javascript", version: "18.15.0" },
100+
typescript: { language: "typescript", version: "5.0.3" },
101+
python: { language: "python", version: "3.10.0" },
102+
c: { language: "c", version: "10.2.0" },
103+
cpp: { language: "c++", version: "10.2.0" },
104+
"c++": { language: "c++", version: "10.2.0" },
105+
java: { language: "java", version: "15.0.2" },
106+
rust: { language: "rust", version: "1.68.2" },
107+
html: { language: "javascript", version: "18.15.0" },
108+
css: { language: "javascript", version: "18.15.0" },
109+
};
110+
111+
function extractFilename(command: string): string | null {
112+
// Match common patterns: python 'file.py', node 'file.js', gcc 'file.c', etc.
113+
const patterns = [
114+
/python\s+'([^']+)'/,
115+
/python\s+(\S+)/,
116+
/node\s+'([^']+)'/,
117+
/node\s+(\S+)/,
118+
/npx\s+tsx\s+'([^']+)'/,
119+
/npx\s+tsx\s+(\S+)/,
120+
/gcc\s+'([^']+)'/,
121+
/gcc\s+(\S+)/,
122+
/g\+\+\s+'([^']+)'/,
123+
/g\+\+\s+(\S+)/,
124+
/javac\s+'([^']+)'/,
125+
/javac\s+(\S+)/,
126+
/rustc\s+'([^']+)'/,
127+
/rustc\s+(\S+)/,
128+
];
129+
for (const pattern of patterns) {
130+
const match = command.match(pattern);
131+
if (match) return match[1];
132+
}
133+
return null;
134+
}
135+
136+
async function runViaPiston(workspaceId: string, language: string, command: string): Promise<{ ok: boolean; output: string }> {
137+
const mapping = pistonLangMap[language];
138+
if (!mapping) {
139+
return { ok: false, output: `Language "${language}" is not supported for online execution.` };
140+
}
141+
142+
// Extract filename from the command and read the source file
143+
const filename = extractFilename(command);
144+
if (!filename) {
145+
return { ok: false, output: `Could not determine source file from command: ${command}` };
146+
}
147+
148+
const cwd = workspacePath(workspaceId);
149+
const filePath = path.join(cwd, filename);
150+
let sourceCode: string;
151+
try {
152+
sourceCode = await readFile(filePath, "utf-8");
153+
} catch {
154+
return { ok: false, output: `File not found: ${filename}` };
155+
}
156+
157+
try {
158+
const controller = new AbortController();
159+
const timeout = setTimeout(() => controller.abort(), 30_000);
160+
161+
const response = await fetch(PISTON_URL, {
162+
method: "POST",
163+
headers: { "Content-Type": "application/json" },
164+
signal: controller.signal,
165+
body: JSON.stringify({
166+
language: mapping.language,
167+
version: mapping.version,
168+
files: [{ name: filename, content: sourceCode }],
169+
}),
170+
});
171+
clearTimeout(timeout);
172+
173+
if (!response.ok) {
174+
return { ok: false, output: `Code execution service returned ${response.status}. Please try again.` };
175+
}
176+
177+
const data = await response.json() as { run?: { stdout?: string; stderr?: string; code?: number }; message?: string };
178+
if (data.message) {
179+
return { ok: false, output: data.message };
180+
}
181+
const run = data.run;
182+
if (!run) {
183+
return { ok: false, output: "Unexpected response from execution service." };
184+
}
185+
186+
const output = [run.stdout, run.stderr].filter(Boolean).join("\n").trim() || "(No output)";
187+
return { ok: run.code === 0, output };
188+
} catch (err: any) {
189+
if (err.name === "AbortError") {
190+
return { ok: false, output: "[TIMEOUT] Execution timed out after 30s" };
191+
}
192+
return { ok: false, output: `Execution service error: ${err.message}` };
193+
}
194+
}

0 commit comments

Comments
 (0)