hardiksood21
refactor: add server job cleanup and handle multer errors gracefully
beac2cb
Raw
History Blame Contribute Delete
14.5 kB
const express = require('express');
const multer = require('multer');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
const archiver = require('archiver');
const https = require('https');
const http = require('http');
const app = express();
const PORT = process.env.PORT || 3001;
// Store job metadata for downloading later
const jobStore = {};
// Google Drive API key β€” set as a Secret in HF Spaces Settings
// (Settings β†’ Variables and Secrets β†’ New Secret: GOOGLE_DRIVE_API_KEY)
const GDRIVE_API_KEY = process.env.GOOGLE_DRIVE_API_KEY || '';
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../frontend/dist')));
// ── Upload dir ────────────────────────────────────────────────────────────
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const u = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, u + '-' + file.originalname);
},
});
const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024 } });
const R_SCRIPT_PATH = path.join(__dirname, '..', 'lc_ms_peak_picker.R');
// ── Helper: run R on one file ─────────────────────────────────────────────
function runRScript(inputFile, outputFile, params) {
return new Promise((resolve, reject) => {
const { ppm, sn, min_pw, max_pw, pref_k, pref_i, sample_name } = params;
const args = [
R_SCRIPT_PATH,
'--input', inputFile, '--output', outputFile,
'--ppm', ppm, '--sn', sn,
'--min_pw', min_pw, '--max_pw', max_pw,
'--pref_k', pref_k, '--pref_i', pref_i,
'--sample_name', sample_name,
];
const proc = spawn('Rscript', args);
let errLog = '';
proc.stdout.on('data', d => console.log(d.toString()));
proc.stderr.on('data', d => { errLog += d; console.error(d.toString()); });
proc.on('close', code => {
if (code !== 0) return reject(new Error(`R failed for ${sample_name}: ${errLog}`));
if (!fs.existsSync(outputFile)) return reject(new Error(`No output for ${sample_name}`));
resolve(outputFile);
});
});
}
// ── Helper: process a list of jobs β†’ { successOutputs, errors } ───────────
async function processJobs(jobs, params) {
const results = await Promise.allSettled(
jobs.map(j => runRScript(j.inputFile, j.outputFile, { ...params, sample_name: j.sample_name }))
);
const successOutputs = [], errors = [];
jobs.forEach((j, i) => {
if (results[i].status === 'fulfilled') {
successOutputs.push({ path: j.outputFile, name: `${j.sample_name}.peaks` });
} else {
errors.push({ file: j.sample_name, reason: results[i].reason?.message });
}
if (fs.existsSync(j.inputFile)) fs.unlinkSync(j.inputFile);
});
return { successOutputs, errors };
}
// ── Helper: create ZIP on disk ──────────────────────────────────────────────
function createZipOnDisk(destPath, successOutputs, errors) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(destPath);
const archive = archiver('zip', { zlib: { level: 6 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
successOutputs.forEach(({ path: p, name }) => archive.file(p, { name }));
if (errors.length > 0) {
archive.append(errors.map(e => `${e.file}: ${e.reason}`).join('\n'), { name: 'errors.txt' });
}
archive.finalize();
}).then(() => {
// Delete individual output files after zipping
successOutputs.forEach(({ path: p }) => { if (fs.existsSync(p)) fs.unlinkSync(p); });
});
}
// ── Helper: follow redirects and download a URL to disk ──────────────────
function downloadFile(url, destPath, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > 10) return reject(new Error('Too many redirects'));
const proto = url.startsWith('https') ? https : http;
const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) {
return downloadFile(res.headers.location, destPath, redirectCount + 1).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode} downloading file`));
}
const out = fs.createWriteStream(destPath);
res.pipe(out);
out.on('finish', () => out.close(resolve));
out.on('error', reject);
});
req.on('error', reject);
});
}
// ── Helper: fetch JSON from HTTPS ─────────────────────────────────────────
function fetchJSON(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('Invalid JSON from Drive API: ' + data.slice(0, 200))); }
});
}).on('error', reject);
});
}
// ── Helper: extract Drive folder ID from URL ──────────────────────────────
function extractFolderId(url) {
const patterns = [/\/folders\/([a-zA-Z0-9_-]{10,})/, /[?&]id=([a-zA-Z0-9_-]{10,})/];
for (const p of patterns) { const m = url.match(p); if (m) return m[1]; }
return null;
}
// ── Helper: list mzML/mzXML files in a public Drive folder via API ────────
async function listDriveFiles(folderId, pageToken = '', allFiles = []) {
const q = encodeURIComponent(`'${folderId}' in parents and trashed = false`);
const fields = encodeURIComponent('nextPageToken,files(id,name,mimeType,size)');
let url = `https://www.googleapis.com/drive/v3/files?q=${q}&fields=${fields}&pageSize=100&key=${GDRIVE_API_KEY}`;
if (pageToken) url += `&pageToken=${encodeURIComponent(pageToken)}`;
const data = await fetchJSON(url);
if (data.error) {
const msg = data.error.message || JSON.stringify(data.error);
throw new Error(`Drive API error: ${msg}`);
}
const supported = (data.files || []).filter(f => {
const n = f.name.toLowerCase();
return n.endsWith('.mzml') || n.endsWith('.mzxml');
});
allFiles.push(...supported);
if (data.nextPageToken) {
return listDriveFiles(folderId, data.nextPageToken, allFiles);
}
return allFiles;
}
// ── Helper: download a file from Drive API (handles large file confirmations)
async function downloadDriveFile(fileId, destPath) {
// Using Drive API v3 alt=media β€” works for public files with API key
const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=${GDRIVE_API_KEY}`;
await downloadFile(url, destPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTES
// ═══════════════════════════════════════════════════════════════════════════
// ── Batch upload endpoint ─────────────────────────────────────────────────
app.post('/api/peak-picker/batch', upload.array('files', 100), async (req, res) => {
if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded.' });
const params = {
ppm: req.body.ppm || '15', sn: req.body.sn || '1.5',
min_pw: req.body.min_pw || '5', max_pw: req.body.max_pw || '20',
pref_k: req.body.pref_k || '0', pref_i: req.body.pref_i || '0',
};
const jobs = req.files.map(f => ({
inputFile: f.path,
outputFile: path.join(uploadDir, `${f.filename}.peaks`),
sample_name: path.parse(f.originalname).name,
}));
const { successOutputs, errors } = await processJobs(jobs, params);
if (!successOutputs.length) return res.status(500).json({ error: 'All files failed.', details: errors });
const jobId = Date.now().toString(36) + '-' + Math.random().toString(36).substr(2, 5);
let finalPath = '';
let finalName = '';
if (successOutputs.length === 1 && !errors.length) {
finalPath = successOutputs[0].path;
finalName = successOutputs[0].name;
} else {
finalPath = path.join(uploadDir, `${jobId}.zip`);
finalName = 'peak_picker_results.zip';
await createZipOnDisk(finalPath, successOutputs, errors);
}
jobStore[jobId] = { path: finalPath, name: finalName, count: successOutputs.length, timestamp: Date.now() };
// Cleanup old jobs (older than 2 hours) to prevent disk filling up
if (Math.random() < 0.1) {
const TWO_HOURS = 2 * 60 * 60 * 1000;
const now = Date.now();
Object.keys(jobStore).forEach(k => {
const job = jobStore[k];
if (job.timestamp && (now - job.timestamp > TWO_HOURS)) {
try {
if (fs.existsSync(job.path)) {
fs.unlinkSync(job.path);
}
delete jobStore[k];
console.log(`Cleaned up expired job: ${k}`);
} catch (e) {
console.error(`Failed to clean up job ${k}:`, e.message);
}
}
});
}
res.json({ success: true, jobId, count: successOutputs.length, total: jobs.length, errors });
});
// ── Google Drive endpoint ─────────────────────────────────────────────────
app.post('/api/peak-picker/gdrive', async (req, res) => {
const { folderUrl, ppm, sn, min_pw, max_pw, pref_k, pref_i } = req.body;
if (!folderUrl) return res.status(400).json({ error: 'No Google Drive folder URL provided.' });
// Check API key is configured
if (!GDRIVE_API_KEY) {
return res.status(500).json({
error: 'Google Drive API key is not configured on the server. Please add GOOGLE_DRIVE_API_KEY as a Secret in HF Spaces Settings.',
});
}
const folderId = extractFolderId(folderUrl);
if (!folderId) return res.status(400).json({ error: 'Could not parse folder ID from URL.' });
// 1. List files
let fileList;
try {
fileList = await listDriveFiles(folderId);
} catch (err) {
return res.status(500).json({ error: `Failed to list Drive folder: ${err.message}` });
}
if (!fileList.length) {
return res.status(404).json({
error: 'No .mzML or .mzXML files found in this folder. Make sure the folder is shared as "Anyone with the link can view" and contains supported files.',
});
}
// 2. Download all files
const params = { ppm: ppm||'15', sn: sn||'1.5', min_pw: min_pw||'5', max_pw: max_pw||'20', pref_k: pref_k||'0', pref_i: pref_i||'0' };
const jobs = [];
const downloadErrors = [];
await Promise.allSettled(fileList.map(async ({ id, name }) => {
const u = Date.now() + '-' + Math.round(Math.random() * 1e9);
const localPath = path.join(uploadDir, `${u}-${name}`);
try {
await downloadDriveFile(id, localPath);
jobs.push({
inputFile: localPath,
outputFile: path.join(uploadDir, `${u}-${path.parse(name).name}.peaks`),
sample_name: path.parse(name).name,
});
} catch (err) {
downloadErrors.push({ file: name, reason: err.message });
}
}));
if (!jobs.length) return res.status(500).json({ error: 'Failed to download any files.', details: downloadErrors });
// 3. Process all
const { successOutputs, errors } = await processJobs(jobs, params);
const allErrors = [...downloadErrors, ...errors];
if (!successOutputs.length) return res.status(500).json({ error: 'All files failed to process.', details: allErrors });
const jobId = Date.now().toString(36) + '-' + Math.random().toString(36).substr(2, 5);
let finalPath = '';
let finalName = '';
if (successOutputs.length === 1 && !allErrors.length) {
finalPath = successOutputs[0].path;
finalName = successOutputs[0].name;
} else {
finalPath = path.join(uploadDir, `${jobId}.zip`);
finalName = 'peak_picker_results.zip';
await createZipOnDisk(finalPath, successOutputs, allErrors);
}
jobStore[jobId] = { path: finalPath, name: finalName, count: successOutputs.length, timestamp: Date.now() };
res.json({ success: true, jobId, count: successOutputs.length, total: jobs.length, errors: allErrors });
});
// ── Download endpoint ─────────────────────────────────────────────────────
app.get('/api/peak-picker/download/:jobId', (req, res) => {
const job = jobStore[req.params.jobId];
if (!job || !fs.existsSync(job.path)) {
return res.status(404).send('Download link expired or invalid.');
}
res.download(job.path, job.name);
});
// ── Check if API key is configured (called by frontend on tab switch) ─────
app.get('/api/gdrive-status', (req, res) => {
res.json({ configured: Boolean(GDRIVE_API_KEY) });
});
// Error handling middleware (catches Multer and other unhandled errors)
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: 'Too many files uploaded. Maximum is 100 files.' });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
console.error('Unhandled error:', err);
res.status(500).json({ error: err.message || 'Internal server error.' });
});
// Catch-all β†’ React
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));