From 457059330c1f33687ad4282732922538dcac3243 Mon Sep 17 00:00:00 2001 From: EmmaYuan1015 Date: Mon, 10 Aug 2026 10:54:43 +0800 Subject: [PATCH] Fix Expo download stream handling --- src/common/downloadHelper.ts | 81 +++++++++++++++++++--------- test/common/downloadHelper.test.ts | 86 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 test/common/downloadHelper.test.ts diff --git a/src/common/downloadHelper.ts b/src/common/downloadHelper.ts index f89889f90..55fbda5bd 100644 --- a/src/common/downloadHelper.ts +++ b/src/common/downloadHelper.ts @@ -3,31 +3,61 @@ import * as fs from "fs"; import * as https from "https"; +import { pipeline } from "stream"; +import { URL } from "url"; import * as vscode from "vscode"; import { OutputChannelLogger } from "../extension/log/OutputChannelLogger"; -export async function downloadFile(url: any, targetFile: any) { +const MAX_REDIRECTS = 5; + +export function downloadFile( + url: string, + targetFile: string, + redirectsRemaining: number = MAX_REDIRECTS, +): Promise { const logger = OutputChannelLogger.getMainChannel(); let progress = 0; - let newProgress = 0; - return await new Promise((resolve, reject) => { + let downloadedLength = 0; + + return new Promise((resolve, reject) => { const request = https .get(url, response => { const code = response.statusCode ?? 0; - if (code >= 400) { - return reject(new Error(response.statusMessage)); + if (code >= 300 && code < 400 && response.headers.location) { + response.resume(); + if (redirectsRemaining === 0) { + reject(new Error(`Too many redirects while downloading ${url}`)); + return; + } + + const redirectUrl = new URL(response.headers.location, url).toString(); + downloadFile(redirectUrl, targetFile, redirectsRemaining - 1).then( + resolve, + reject, + ); + return; + } + + if (code < 200 || code >= 300) { + response.resume(); + reject( + new Error( + `Download failed with HTTP status ${code}${ + response.statusMessage ? ` ${response.statusMessage}` : "" + }`, + ), + ); + return; } const file = fs.createWriteStream(targetFile); - const totalLength = parseInt(response.headers["content-length"] as string, 10); + const totalLength = Number(response.headers["content-length"]); - response.pipe(file); - response.on("data", async function (chunk) { - newProgress += chunk.length; - const currentProgress = - parseFloat(getDownloadProgress(newProgress, totalLength)) * 100; - if (currentProgress - progress >= 5) { + response.on("data", (chunk: Buffer) => { + downloadedLength += chunk.length; + const currentProgress = getDownloadProgress(downloadedLength, totalLength); + if (currentProgress !== undefined && currentProgress - progress >= 5) { progress = currentProgress; logger.logStream( `Current progress: ${currentProgress}%, please wait... \n`, @@ -35,16 +65,15 @@ export async function downloadFile(url: any, targetFile: any) { } }); - file.on("finish", async () => { - file.close(); - logger.logStream(`Download Expo Go Completed: ${targetFile as string} \n`); - void vscode.window.showInformationMessage("Download Expo Go Completed."); - }); + pipeline(response, file, error => { + if (error) { + fs.unlink(targetFile, () => reject(error)); + return; + } - response.on("end", function () { - resolve(() => { - console.log("Progress end."); - }); + logger.logStream(`Download Expo Go Completed: ${targetFile} \n`); + void vscode.window.showInformationMessage("Download Expo Go Completed."); + resolve(); }); }) .on("error", error => { @@ -55,10 +84,14 @@ export async function downloadFile(url: any, targetFile: any) { }); } -export async function downloadExpoGo(url: string, targetFile: string) { +export async function downloadExpoGo(url: string, targetFile: string): Promise { await downloadFile(url, targetFile); } -function getDownloadProgress(currentLength: number, totalLength: number): string { - return (currentLength / totalLength).toFixed(2); +function getDownloadProgress(currentLength: number, totalLength: number): number | undefined { + if (!Number.isFinite(totalLength) || totalLength <= 0) { + return undefined; + } + + return Math.floor((currentLength / totalLength) * 100); } diff --git a/test/common/downloadHelper.test.ts b/test/common/downloadHelper.test.ts new file mode 100644 index 000000000..c6a8e82ad --- /dev/null +++ b/test/common/downloadHelper.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import assert = require("assert"); +import { EventEmitter } from "events"; +import proxyquire = require("proxyquire"); + +suite("downloadHelper", function () { + type Pipeline = ( + source: unknown, + destination: unknown, + callback: (error?: Error | null) => void, + ) => void; + type Unlink = (path: string, callback: (error?: NodeJS.ErrnoException) => void) => void; + + function createDownloadHelper( + pipelineStub: Pipeline, + unlinkStub: Unlink = () => undefined, + ): { downloadFile: (url: string, targetFile: string) => Promise } { + const response = new EventEmitter() as any; + response.statusCode = 200; + response.headers = { "content-length": "10" }; + response.resume = () => undefined; + + const request = new EventEmitter() as any; + request.end = () => undefined; + + const getStub = (_url: string, callback: (value: any) => void) => { + callback(response); + return request; + }; + + return proxyquire.noCallThru()("../../src/common/downloadHelper", { + fs: { + createWriteStream: () => ({}), + unlink: unlinkStub, + }, + https: { get: getStub }, + stream: { pipeline: pipelineStub }, + vscode: { window: { showInformationMessage: () => undefined } }, + "../extension/log/OutputChannelLogger": { + OutputChannelLogger: { + getMainChannel: () => ({ logStream: () => undefined }), + }, + }, + }); + } + + test("resolves only after the file pipeline finishes", async function () { + let pipelineCallback: ((error?: Error | null) => void) | undefined; + const pipelineStub: Pipeline = (_source, _destination, callback) => { + pipelineCallback = callback; + }; + const { downloadFile } = createDownloadHelper(pipelineStub); + + let resolved = false; + const download = downloadFile("https://example.com/expo.apk", "expo.apk").then(() => { + resolved = true; + }); + await Promise.resolve(); + + assert.strictEqual(resolved, false); + pipelineCallback?.(); + await download; + assert.strictEqual(resolved, true); + }); + + test("removes the partial file when the pipeline fails", async function () { + const pipelineError = new Error("write failed"); + const pipelineStub: Pipeline = (_source, _destination, callback) => { + callback(pipelineError); + }; + let removedPath: string | undefined; + const unlinkStub: Unlink = (path, callback) => { + removedPath = path; + callback(); + }; + const { downloadFile } = createDownloadHelper(pipelineStub, unlinkStub); + + await assert.rejects( + downloadFile("https://example.com/expo.apk", "expo.apk"), + pipelineError, + ); + assert.strictEqual(removedPath, "expo.apk"); + }); +});