Skip to content

Commit 6d76b33

Browse files
authored
Merge pull request #3862 from Nixxx19/fix/download-project-streaming-zip-#3780
Fix: Stream project ZIP download to prevent 502 timeout and memory issues #3780
2 parents 8b36e1e + 402d45e commit 6d76b33

1 file changed

Lines changed: 126 additions & 31 deletions

File tree

server/controllers/project.controller.js

Lines changed: 126 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@ import mime from 'mime';
66
import isAfter from 'date-fns/isAfter';
77
import axios from 'axios';
88
import slugify from 'slugify';
9+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
910
import Project from '../models/project';
1011
import { User } from '../models/user';
1112
import { resolvePathToFile } from '../utils/filePath';
1213
import { generateFileSystemSafeName } from '../utils/generateFileSystemSafeName';
1314

15+
const s3Client = new S3Client({
16+
credentials: {
17+
accessKeyId: process.env.AWS_ACCESS_KEY,
18+
secretAccessKey: process.env.AWS_SECRET_KEY
19+
},
20+
region: process.env.AWS_REGION
21+
});
22+
1423
export {
1524
default as createProject,
1625
apiCreateProject
@@ -166,7 +175,7 @@ export async function projectExists(projectId) {
166175

167176
/**
168177
* @param {string} username
169-
* @param {string} projectId - the database id or the slug or the project
178+
* @param {string} projectId
170179
* @return {Promise<boolean>}
171180
*/
172181
export async function projectForUserExists(username, projectId) {
@@ -197,13 +206,14 @@ export async function getProjectForUser(username, projectId) {
197206
}
198207

199208
/**
200-
* Adds URLs referenced in <script> tags to the `files` array of the project
201-
* so that they can be downloaded along with other remote files from S3.
202209
* @param {object} project
203-
* @void - modifies the `project` parameter
204210
*/
205211
function bundleExternalLibs(project) {
206212
const indexHtml = project.files.find((file) => file.name === 'index.html');
213+
if (!indexHtml || !indexHtml.content) {
214+
return;
215+
}
216+
207217
const { window } = new JSDOM(indexHtml.content);
208218
const scriptTags = window.document.getElementsByTagName('script');
209219

@@ -213,6 +223,10 @@ function bundleExternalLibs(project) {
213223
const path = src.split('/');
214224
const filename = path[path.length - 1];
215225

226+
if (project.files.some((f) => f.name === filename && f.url === src)) {
227+
return;
228+
}
229+
216230
project.files.push({
217231
name: filename,
218232
url: src
@@ -224,38 +238,74 @@ function bundleExternalLibs(project) {
224238
}
225239

226240
/**
227-
* Recursively adds a file and all of its children to the JSZip instance.
241+
* @param {string} url - S3 URL
242+
* @return {Promise<Readable>}
243+
*/
244+
async function getStreamFromS3Url(url) {
245+
const urlObj = new URL(url);
246+
let bucket;
247+
let key;
248+
249+
if (urlObj.hostname.includes('s3')) {
250+
if (urlObj.hostname.startsWith('s3')) {
251+
const pathParts = urlObj.pathname.split('/').filter(Boolean);
252+
[bucket] = pathParts;
253+
key = pathParts.slice(1).join('/');
254+
} else {
255+
[bucket] = urlObj.hostname.split('.');
256+
key = urlObj.pathname.substring(1);
257+
}
258+
259+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
260+
const response = await s3Client.send(command);
261+
return response.Body;
262+
}
263+
264+
const response = await axios.get(url, {
265+
responseType: 'stream',
266+
timeout: 30000
267+
});
268+
return response.data;
269+
}
270+
271+
/**
228272
* @param {object} file
229273
* @param {Array<object>} files
230274
* @param {JSZip} zip
231-
* @return {Promise<void>} - modifies the `zip` parameter
275+
* @return {Promise<void>}
232276
*/
233277
async function addFileToZip(file, files, zip) {
234278
if (file.fileType === 'folder') {
235279
const folderZip = file.name === 'root' ? zip : zip.folder(file.name);
236-
await Promise.all(
237-
file.children.map((fileId) => {
238-
const childFile = files.find((f) => f.id === fileId);
239-
return addFileToZip(childFile, files, folderZip);
240-
})
241-
);
280+
await file.children.reduce(async (previousPromise, fileId) => {
281+
await previousPromise;
282+
const childFile = files.find((f) => f.id === fileId);
283+
return addFileToZip(childFile, files, folderZip);
284+
}, Promise.resolve());
242285
} else if (file.url) {
243286
try {
244-
const res = await axios.get(file.url, {
245-
responseType: 'arraybuffer',
246-
timeout: 30000 // 30 second timeout to prevent hanging requests
247-
});
248-
zip.file(file.name, res.data);
287+
if (file.url.includes('s3') && file.url.includes('amazonaws.com')) {
288+
const stream = await getStreamFromS3Url(file.url);
289+
zip.file(file.name, stream, { binary: true });
290+
} else {
291+
const response = await axios.get(file.url, {
292+
responseType: 'stream',
293+
timeout: 30000
294+
});
295+
zip.file(file.name, response.data, { binary: true });
296+
}
249297
} catch (e) {
250298
console.warn(`Failed to fetch file from ${file.url}:`, e.message);
251-
zip.file(file.name, new ArrayBuffer(0));
299+
zip.file(file.name, Buffer.alloc(0));
252300
}
253301
} else {
254302
zip.file(file.name, file.content);
255303
}
256304
}
257305

258306
async function buildZip(project, req, res) {
307+
let keepaliveInterval;
308+
259309
try {
260310
const zip = new JSZip();
261311
const currentTime = format(new Date(), 'yyyy_MM_dd_HH_mm_ss');
@@ -266,30 +316,77 @@ async function buildZip(project, req, res) {
266316
const { files } = project;
267317
const root = files.find((file) => file.name === 'root');
268318

319+
if (!root) {
320+
throw new Error('Project has no root folder');
321+
}
322+
269323
bundleExternalLibs(project);
324+
325+
res.writeHead(200, {
326+
'Content-Type': 'application/zip',
327+
'Content-disposition': `attachment; filename=${zipFileName}`,
328+
'Transfer-Encoding': 'chunked'
329+
});
330+
331+
let keepaliveCounter = 0;
332+
keepaliveInterval = setInterval(() => {
333+
if (!res.writableEnded) {
334+
res.write(Buffer.alloc(0));
335+
keepaliveCounter++;
336+
if (keepaliveCounter % 10 === 0) {
337+
console.log(
338+
`Keepalive: Building ZIP file list (${keepaliveCounter}s elapsed)...`
339+
);
340+
}
341+
}
342+
}, 1000);
343+
270344
await addFileToZip(root, files, zip);
271345

272-
const base64 = await zip.generateAsync({ type: 'base64' });
273-
const buff = Buffer.from(base64, 'base64');
346+
clearInterval(keepaliveInterval);
347+
keepaliveInterval = null;
274348

275-
// nityam Check if response was already sent (e.g., client disconnected)
276-
if (res.headersSent) {
277-
return;
278-
}
349+
const zipStream = zip.generateNodeStream({
350+
type: 'nodebuffer',
351+
streamFiles: true,
352+
compression: 'DEFLATE',
353+
compressionOptions: { level: 6 }
354+
});
279355

280-
res.writeHead(200, {
281-
'Content-Type': 'application/zip',
282-
'Content-disposition': `attachment; filename=${zipFileName}`
356+
zipStream.pipe(res);
357+
358+
zipStream.on('error', (err) => {
359+
console.error('Error streaming zip file:', err);
360+
if (!res.headersSent) {
361+
res.status(500).json({
362+
success: false,
363+
message: 'Failed to generate zip file. Please try again.'
364+
});
365+
} else {
366+
res.end();
367+
}
368+
});
369+
370+
await new Promise((resolve, reject) => {
371+
zipStream.on('end', resolve);
372+
zipStream.on('error', reject);
373+
res.on('error', reject);
374+
res.on('close', () => reject(new Error('Client disconnected')));
283375
});
284-
res.end(buff);
285376
} catch (err) {
286377
console.error('Error building zip file:', err);
287-
// Only send error if response hasn't been sent yet
378+
379+
if (keepaliveInterval) {
380+
clearInterval(keepaliveInterval);
381+
}
382+
288383
if (!res.headersSent) {
289384
res.status(500).json({
290385
success: false,
291386
message: 'Failed to generate zip file. Please try again.'
292387
});
388+
} else {
389+
res.end();
293390
}
294391
}
295392
}
@@ -301,11 +398,9 @@ export async function downloadProjectAsZip(req, res) {
301398
res.status(404).send({ message: 'Project with that id does not exist' });
302399
return;
303400
}
304-
// Await buildZip to ensure it completes before the function returns
305401
await buildZip(project, req, res);
306402
} catch (err) {
307403
console.error('Error in downloadProjectAsZip:', err);
308-
// Only send error if response hasn't been sent yet
309404
if (!res.headersSent) {
310405
res.status(500).json({
311406
success: false,

0 commit comments

Comments
 (0)