Thanks to help from the javascript-list@gnome.org, I've figured it out. It turns out Soup.Message has events that you can bind to, including one called got_chunk and one called got_headers.
const Soup = imports.gi.Soup;
const Lang = imports.lang;
var _httpSession = new Soup.SessionAsync();
Soup.Session.prototype.add_feature.call(_httpSession, new Soup.ProxyResolverDefault());
// variables for the progress bar
var total_size;
var bytes_so_far = 0;
// create an http message
var request = Soup.Message.new('GET', url);
// got_headers event
request.connect('got_headers', Lang.bind(this, function(message){
total_size = message.response_headers.get_content_length()
}));
// got_chunk event
request.connect('got_chunk', Lang.bind(this, function(message, chunk){
bytes_so_far += chunk.length;
if(total_size) {
let fraction = bytes_so_far / total_size;
let percent = Math.floor(fraction * 100);
print("Download "+percent+"% done ("+bytes_so_far+" / "+total_size+" bytes)");
}
}));
// queue the http request
_httpSession.queue_message(request, function(_httpSession, message) {
print('Download is done');
});