upload-progress.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import prettyBytes from 'pretty-bytes'
  2. import ProgressBar from 'progress'
  3. const PROGRESS_BAR_FORMAT = '[:bar]'
  4. const PROGRESS_BAR_WIDTH = 24
  5. function clamp(value, min, max) {
  6. return Math.min(Math.max(value, min), max)
  7. }
  8. export function formatPercent(value) {
  9. return `${clamp(value, 0, 100).toFixed(1)}%`
  10. }
  11. export function formatEta(seconds) {
  12. if (!Number.isFinite(seconds) || seconds < 0) {
  13. return '--:--'
  14. }
  15. const roundedSeconds = Math.ceil(seconds)
  16. const hours = Math.floor(roundedSeconds / 3600)
  17. const minutes = Math.floor((roundedSeconds % 3600) / 60)
  18. const secs = roundedSeconds % 60
  19. if (hours > 0) {
  20. return [ hours, minutes, secs ].map((value) => String(value).padStart(2, '0')).join(':')
  21. }
  22. return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
  23. }
  24. export function truncateFileName(fileName, maxLength = 36) {
  25. if (fileName.length <= maxLength) {
  26. return fileName
  27. }
  28. if (maxLength <= 3) {
  29. return fileName.slice(0, maxLength)
  30. }
  31. const extensionIndex = fileName.lastIndexOf('.')
  32. const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ''
  33. const suffixLength = clamp(extension.length + 10, 8, maxLength - 3)
  34. const prefixLength = Math.max(maxLength - suffixLength - 3, 1)
  35. return `${fileName.slice(0, prefixLength)}...${fileName.slice(-suffixLength)}`
  36. }
  37. export function formatProgressMessage(snapshot) {
  38. return (
  39. `progress ${formatPercent(snapshot.totalPercent)} ` +
  40. `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
  41. `current ${formatPercent(snapshot.currentFilePercent)} ` +
  42. `speed ${snapshot.speedLabel} ` +
  43. `eta ${snapshot.etaLabel} ` +
  44. `${snapshot.transferredLabel}/${snapshot.totalLabel} ` +
  45. `${snapshot.displayFileName}`
  46. )
  47. }
  48. export function fitFileNameToWidth(fileName, availableWidth, fallbackMaxLength = 36) {
  49. if (!Number.isFinite(availableWidth)) {
  50. return fileName
  51. }
  52. if (availableWidth <= 0) {
  53. return truncateFileName(fileName, Math.max(fallbackMaxLength, 8))
  54. }
  55. if (fileName.length <= availableWidth) {
  56. return fileName
  57. }
  58. return truncateFileName(fileName, Math.max(Math.floor(availableWidth), 8))
  59. }
  60. export function formatTtyProgressLines(snapshot, barText, columns) {
  61. const firstLine = `upload ${barText} ${formatPercent(snapshot.totalPercent)} ${snapshot.transferredLabel}/${snapshot.totalLabel}`
  62. const secondLinePrefix =
  63. `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
  64. `current ${formatPercent(snapshot.currentFilePercent)} ` +
  65. `speed ${snapshot.speedLabel} ` +
  66. `eta ${snapshot.etaLabel} `
  67. const displayFileName = fitFileNameToWidth(
  68. snapshot.currentFileName || snapshot.displayFileName,
  69. typeof columns === 'number' ? columns - secondLinePrefix.length : undefined,
  70. )
  71. return [
  72. firstLine,
  73. `${secondLinePrefix}${displayFileName}`,
  74. ]
  75. }
  76. function buildSnapshot(state, now) {
  77. const elapsedSeconds =
  78. state.startedAt === null ? 0 : Math.max((now() - state.startedAt) / 1000, 0)
  79. const speedBytesPerSecond =
  80. elapsedSeconds > 0 ? state.totalUploadedBytes / elapsedSeconds : 0
  81. const remainingBytes = Math.max(state.totalBytes - state.totalUploadedBytes, 0)
  82. const etaSeconds =
  83. remainingBytes === 0 ? 0 : speedBytesPerSecond > 0 ? remainingBytes / speedBytesPerSecond : null
  84. const totalPercent =
  85. state.totalBytes === 0 ? (state.finished ? 100 : 0) : (state.totalUploadedBytes / state.totalBytes) * 100
  86. const currentFilePercent =
  87. state.currentFileSize === 0
  88. ? state.currentFileComplete
  89. ? 100
  90. : 0
  91. : (state.currentFileBytes / state.currentFileSize) * 100
  92. return {
  93. currentFileBytes: state.currentFileBytes,
  94. currentFileIndex: state.currentFileIndex,
  95. currentFileName: state.currentFileName,
  96. currentFilePercent: clamp(currentFilePercent, 0, 100),
  97. currentFileSize: state.currentFileSize,
  98. displayFileName: state.currentFileName || '-',
  99. etaLabel: formatEta(etaSeconds),
  100. etaSeconds,
  101. speedBytesPerSecond,
  102. speedLabel: `${prettyBytes(speedBytesPerSecond)}/s`,
  103. totalBytes: state.totalBytes,
  104. totalFiles: state.totalFiles,
  105. totalPercent: clamp(totalPercent, 0, 100),
  106. totalUploadedBytes: state.totalUploadedBytes,
  107. totalLabel: prettyBytes(state.totalBytes),
  108. transferredLabel: prettyBytes(state.totalUploadedBytes),
  109. }
  110. }
  111. function createCaptureStream(columns = 120) {
  112. let buffer = ''
  113. return {
  114. clearBuffer() {
  115. buffer = ''
  116. },
  117. clearLine() {},
  118. columns,
  119. cursorTo() {
  120. buffer = ''
  121. },
  122. isTTY: true,
  123. moveCursor() {},
  124. write(chunk) {
  125. buffer += chunk
  126. return true
  127. },
  128. get value() {
  129. return buffer
  130. },
  131. }
  132. }
  133. export function createUploadProgressTracker({
  134. totalBytes,
  135. totalFiles,
  136. isTTY = Boolean(process.stdout.isTTY),
  137. log = console.log,
  138. now = () => Date.now(),
  139. percentStep = 5,
  140. ProgressBarClass = ProgressBar,
  141. stream = process.stdout,
  142. throttleMs = 1000,
  143. } = {}) {
  144. const state = {
  145. currentFileBytes: 0,
  146. currentFileComplete: false,
  147. currentFileIndex: 0,
  148. currentFileName: '',
  149. currentFileSize: 0,
  150. finished: false,
  151. startedAt: null,
  152. totalBytes,
  153. totalFiles,
  154. totalUploadedBytes: 0,
  155. }
  156. let lastLoggedAt = -Infinity
  157. let lastLoggedBucket = -1
  158. let hasRendered = false
  159. const progressTotal = Math.max(totalBytes, 1)
  160. const barCaptureStream = createCaptureStream()
  161. const bar =
  162. isTTY && totalFiles > 0
  163. ? new ProgressBarClass(PROGRESS_BAR_FORMAT, {
  164. clear: false,
  165. complete: '=',
  166. incomplete: ' ',
  167. renderThrottle: 100,
  168. stream: barCaptureStream,
  169. total: progressTotal,
  170. width: PROGRESS_BAR_WIDTH,
  171. })
  172. : null
  173. function safeClearLine(direction = 0) {
  174. stream.clearLine?.(direction)
  175. }
  176. function safeCursorTo(column = 0) {
  177. stream.cursorTo?.(column)
  178. }
  179. function safeMoveCursor(dx = 0, dy = 0) {
  180. stream.moveCursor?.(dx, dy)
  181. }
  182. function getBarText(snapshot, force = false) {
  183. if (!bar) {
  184. return ''
  185. }
  186. const ratio = progressTotal > 0 ? clamp(snapshot.totalUploadedBytes / progressTotal, 0, 1) : 0
  187. barCaptureStream.clearBuffer()
  188. if (force) {
  189. bar.update(ratio)
  190. bar.render(undefined, true)
  191. } else {
  192. bar.update(ratio)
  193. }
  194. return barCaptureStream.value || bar.lastDraw || '[]'
  195. }
  196. function clearTTYRender() {
  197. if (!hasRendered || !stream.isTTY) {
  198. return
  199. }
  200. safeClearLine(0)
  201. safeCursorTo(0)
  202. safeMoveCursor(0, -1)
  203. safeClearLine(0)
  204. safeCursorTo(0)
  205. }
  206. function renderTTY(force = false) {
  207. const snapshot = getSnapshot()
  208. const barText = getBarText(snapshot, force)
  209. const [ firstLine, secondLine ] = formatTtyProgressLines(snapshot, barText, stream.columns)
  210. if (hasRendered) {
  211. clearTTYRender()
  212. }
  213. stream.write(firstLine)
  214. safeClearLine(1)
  215. stream.write('\n')
  216. stream.write(secondLine)
  217. safeClearLine(1)
  218. hasRendered = true
  219. return snapshot
  220. }
  221. function terminateTTYRender() {
  222. if (!hasRendered || !stream.isTTY) {
  223. return
  224. }
  225. stream.write('\n')
  226. }
  227. function ensureStarted() {
  228. if (state.startedAt === null) {
  229. state.startedAt = now()
  230. }
  231. }
  232. function getSnapshot() {
  233. return buildSnapshot(state, now)
  234. }
  235. function logSnapshot(force = false) {
  236. const snapshot = getSnapshot()
  237. const currentBucket =
  238. percentStep > 0 ? Math.floor(snapshot.totalPercent / percentStep) : Number.POSITIVE_INFINITY
  239. if (
  240. !force &&
  241. now() - lastLoggedAt < throttleMs &&
  242. currentBucket <= lastLoggedBucket
  243. ) {
  244. return snapshot
  245. }
  246. lastLoggedAt = now()
  247. lastLoggedBucket = currentBucket
  248. log(formatProgressMessage(snapshot))
  249. return snapshot
  250. }
  251. function render(force = false) {
  252. if (bar) {
  253. return renderTTY(force)
  254. }
  255. return logSnapshot(force)
  256. }
  257. function advance(deltaBytes) {
  258. if (deltaBytes <= 0) {
  259. return getSnapshot()
  260. }
  261. ensureStarted()
  262. const remainingFile = Math.max(state.currentFileSize - state.currentFileBytes, 0)
  263. const remainingTotal = Math.max(state.totalBytes - state.totalUploadedBytes, 0)
  264. const safeDelta = Math.min(deltaBytes, remainingFile, remainingTotal)
  265. if (safeDelta <= 0) {
  266. return getSnapshot()
  267. }
  268. state.currentFileBytes += safeDelta
  269. state.totalUploadedBytes += safeDelta
  270. return render()
  271. }
  272. function startFile(file, fileIndex) {
  273. ensureStarted()
  274. state.currentFileBytes = 0
  275. state.currentFileComplete = false
  276. state.currentFileIndex = fileIndex
  277. state.currentFileName = file.name
  278. state.currentFileSize = file.size
  279. return render(true)
  280. }
  281. function completeFile() {
  282. const remainingBytes = Math.max(state.currentFileSize - state.currentFileBytes, 0)
  283. if (remainingBytes > 0) {
  284. advance(remainingBytes)
  285. }
  286. state.currentFileComplete = true
  287. return render(true)
  288. }
  289. function resetCurrentFile() {
  290. state.totalUploadedBytes = clamp(
  291. state.totalUploadedBytes - state.currentFileBytes,
  292. 0,
  293. Math.max(state.totalBytes, 0),
  294. )
  295. state.currentFileBytes = 0
  296. state.currentFileComplete = false
  297. return render(true)
  298. }
  299. function finish() {
  300. state.finished = true
  301. if (bar) {
  302. const snapshot = renderTTY(true)
  303. terminateTTYRender()
  304. return snapshot
  305. }
  306. return logSnapshot(true)
  307. }
  308. function fail(fileName = state.currentFileName) {
  309. const snapshot = getSnapshot()
  310. if (bar) {
  311. renderTTY(true)
  312. terminateTTYRender()
  313. }
  314. log(
  315. `upload failed at file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
  316. `${truncateFileName(fileName || snapshot.currentFileName || '-')} ` +
  317. `(${formatPercent(snapshot.currentFilePercent)} current, ` +
  318. `${formatPercent(snapshot.totalPercent)} total, ` +
  319. `${snapshot.speedLabel}, eta ${snapshot.etaLabel}, ` +
  320. `${snapshot.transferredLabel}/${snapshot.totalLabel})`,
  321. )
  322. }
  323. function interrupt(message) {
  324. if (bar && hasRendered) {
  325. clearTTYRender()
  326. stream.write(message)
  327. stream.write('\n')
  328. renderTTY(true)
  329. return
  330. }
  331. log(message)
  332. }
  333. return {
  334. advance,
  335. completeFile,
  336. fail,
  337. finish,
  338. getSnapshot,
  339. interrupt,
  340. resetCurrentFile,
  341. startFile,
  342. }
  343. }