@@ -6,11 +6,20 @@ import mime from 'mime';
66import isAfter from 'date-fns/isAfter' ;
77import axios from 'axios' ;
88import slugify from 'slugify' ;
9+ import { S3Client , GetObjectCommand } from '@aws-sdk/client-s3' ;
910import Project from '../models/project' ;
1011import { User } from '../models/user' ;
1112import { resolvePathToFile } from '../utils/filePath' ;
1213import { 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+
1423export {
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 */
172181export 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 */
205211function 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 */
233277async 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
258306async 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