This commit is contained in:
Aaron Bird 2026-03-27 09:23:36 -05:00 committed by GitHub
commit 1f7690f3bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -6,6 +6,7 @@ import axios from 'axios'
import { Transform } from 'stream' import { Transform } from 'stream'
import { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from './fs.js' import { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from './fs.js'
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
import { rename } from 'fs/promises'
import path from 'path' import path from 'path'
/** /**
@ -27,13 +28,16 @@ export async function doResumableDownload({
const dirname = path.dirname(filepath) const dirname = path.dirname(filepath)
await ensureDirectoryExists(dirname) await ensureDirectoryExists(dirname)
// Check if partial file exists for resume // Stage download to a .tmp file so consumers (e.g. Kiwix) never see a partial file
const tempPath = filepath + '.tmp'
// Check if partial .tmp file exists for resume
let startByte = 0 let startByte = 0
let appendMode = false let appendMode = false
const existingStats = await getFileStatsIfExists(filepath) const existingStats = await getFileStatsIfExists(tempPath)
if (existingStats && !forceNew) { if (existingStats && !forceNew) {
startByte = existingStats.size startByte = Number(existingStats.size)
appendMode = true appendMode = true
} }
@ -55,14 +59,24 @@ export async function doResumableDownload({
} }
} }
// If file is already complete and not forcing overwrite just return filepath // If final file already exists at correct size, return early (idempotent)
if (startByte === totalBytes && totalBytes > 0 && !forceNew) { const finalFileStats = await getFileStatsIfExists(filepath)
if (finalFileStats && Number(finalFileStats.size) === totalBytes && totalBytes > 0 && !forceNew) {
return filepath return filepath
} }
// If server doesn't support range requests and we have a partial file, delete it // If .tmp file is already at correct size (complete but never renamed), just rename it
if (startByte === totalBytes && totalBytes > 0 && !forceNew) {
await rename(tempPath, filepath)
if (onComplete) {
await onComplete(url, filepath)
}
return filepath
}
// If server doesn't support range requests and we have a partial .tmp file, delete it
if (!supportsRangeRequests && startByte > 0) { if (!supportsRangeRequests && startByte > 0) {
await deleteFileIfExists(filepath) await deleteFileIfExists(tempPath)
startByte = 0 startByte = 0
appendMode = false appendMode = false
} }
@ -131,7 +145,7 @@ export async function doResumableDownload({
}, },
}) })
const writeStream = createWriteStream(filepath, { const writeStream = createWriteStream(tempPath, {
flags: appendMode ? 'a' : 'w', flags: appendMode ? 'a' : 'w',
}) })
@ -157,6 +171,13 @@ export async function doResumableDownload({
writeStream.on('finish', async () => { writeStream.on('finish', async () => {
clearStallTimer() clearStallTimer()
try {
// Atomically move the completed .tmp file to the final path
await rename(tempPath, filepath)
} catch (renameError) {
reject(renameError)
return
}
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
downloadedBytes, downloadedBytes,