jimmywarting / StreamSaver.js

StreamSaver writes stream to the filesystem directly asynchronous
https://jimmywarting.github.io/StreamSaver.js/example.html
MIT License
3.98k stars 413 forks source link

Cannot abort a streamsaver WriteStream #149

Closed jat255 closed 4 years ago

jat255 commented 4 years ago

I am not really a javascript developer, so forgive me if I'm missing something obvious, but I'm having trouble creating a button that will cancel an in-progress download using StreamSaver.

My application is using StreamSaver.js, zip-stream.js, and web-streams-polyfill (to provide pipes and TranformStreams in Firefox) and web-streams-adapter (to allow use of pipes on a Fetch API call in Firefox). I have different download functions for zipping files and then for some single large files, and neither can be canceled due to locked writers. Specifically, the error I get when attempting to use writeStream.abort() is Promise {<rejected>: TypeError: Failed to execute 'abort' on 'WritableStream': Cannot abort a locked stream. I'm using a TransformStream to accumulate the number of bytes downloaded to update a progress bar.

The code is basically this (for downloading a single large file):

p = new TransformStream({
    transform (chunk, ctrl) {
        bytesDownloaded += chunk.byteLength 
        updateProgressBar(bytesDownloaded, total_to_dl);
        ctrl.enqueue(chunk)
    }
});

toPonyRS = WebStreamsAdapter.createReadableStreamWrapper(ponyfill.ReadableStream)
fileStream = streamSaver.createWriteStream(filename, {size: window.file_sizes[url]});
fetch(url).then(res =>  {
    rs = res.body;
    rs = window.ReadableStream.prototype.pipeTo ?
           rs : toPonyRS(rs);
    return rs.pipeThrough(p).pipeTo(fileStream);
})

I've attached an event handler to a cancel button that tries to call fileStream.abort(), but then this gives an error because the stream is locked. Am I doing something wrong, or is this not possible? When canceling the download from the browser, the actual download still continues (I think this is #13), so I was hoping to provide a means for the users to cancel the download (currently the only thing that works is refreshing or closing the page.

jat255 commented 4 years ago

@jimmywarting sorry to bug, but any thoughts on this issue?

jimmywarting commented 4 years ago
// don't
ws = new WritableStream()
writer = ws.getWriter() // locks the stream
ws.abort() // Failed to execute 'abort' on 'WritableStream': Cannot abort a locked stream

// do
ws = new WritableStream()
writer = ws.getWriter() // locks the stream
writer.abort()

// or
ws = new WritableStream()
writer = ws.getWriter() // locks the stream
writer.releaseLock() // releases the lock
ws.abort()

as for the browser ui when they cancel, it don't properly propegate back to the service worker when it's aborted. so yea, it's related to #13

if you provide them with a abort button then you could perhaps cancel both the fetch and the writeable stream with AbortController/AbortSignal

other (perhaps easier) solution could have you tried just using a link <a download="filename" href="url">download</a> instead of emulating what a server dose with streamsaver, just provide a content-disposition attachment response header from the backend.

jat255 commented 4 years ago

Thanks! Can I still use this approach with pipeTo/pipeThrough? It appears those methods use the streams directly, rather than the writers/readers, but perhaps I'm misunderstanding.

I'm doing some individual file downloading this way (which I could do directly in the browser as you mention), but since I'm also using zip-stream.js, I wanted to do it all the same way so I could have one progress bar for all the downloads (regardless of if they're individual files or in a zip).

I'm doing the following for the zips, but I'm not sure where I would use the getWriter() in this example:

let writeStream = streamSaver.createWriteStream(
    zip_title,
    { size: total_bytes});

z = new ZIP({
    pull (ctrl) {
        const it = files.next()
        if (it.done) {
            ctrl.close()
        } else {
           const [name, url] = it.value

           return fetch(url).then(res => {
               ctrl.enqueue({
                   name,
                   stream: () => {
                       r = res.body;
                       return r
                   }
               });
           })
       }
  }}).pipeThrough(progressStream)
     .pipeTo(writeStream)
     .catch( err => {
         console.log('failed to save zip');
         errorProgress();
         showError('something went wrong');
});

EDIT: I'll look more into AbortController/AbortSignal... That looks like it might be what I need.

jat255 commented 4 years ago

Fantastic! AbortController was exactly what I needed. MWE for those coming to this later:

var abortController = new AbortController();
var abortSignal = abortController.signal;

z = new ZIP({
    pull (ctrl) {
        const it = files.next()
        if (it.done) {
            ctrl.close()
        } else {
            const [name, url] = it.value

            return fetch(url, 
                            {signal: abortSignal})
                    .then(res => {
                        ctrl.enqueue({
                            name,
                            stream: () => {
                                r = res.body;
                                return r
                            }
                        });
                    })
        }
    }
}).pipeThrough(progressTransform)
    .pipeTo(writeStream,
            {signal: abortSignal})
    .catch( err => {
        if (abortSignal.aborted) {
            console.log('User clicked cancel');
        } else { 
            console.log('There was an error during the download:', err.message);
        }
});

An example of this might be good to add to the docs somewhere (maybe this is obvious to an actual JS developer, but as a hacker this took me a while to figure out).