witersen
3 years ago
6 changed files with 501 additions and 31 deletions
@ -0,0 +1,317 @@ |
|||||||
|
/*! streamsaver. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */ |
||||||
|
|
||||||
|
/* global chrome location ReadableStream define MessageChannel TransformStream */ |
||||||
|
|
||||||
|
;((name, definition) => { |
||||||
|
typeof module !== 'undefined' |
||||||
|
? module.exports = definition() |
||||||
|
: typeof define === 'function' && typeof define.amd === 'object' |
||||||
|
? define(definition) |
||||||
|
: this[name] = definition() |
||||||
|
})('streamSaver', () => { |
||||||
|
'use strict' |
||||||
|
|
||||||
|
const global = typeof window === 'object' ? window : this |
||||||
|
if (!global.HTMLElement) console.warn('streamsaver is meant to run on browsers main thread') |
||||||
|
|
||||||
|
let mitmTransporter = null |
||||||
|
let supportsTransferable = false |
||||||
|
const test = fn => { try { fn() } catch (e) {} } |
||||||
|
const ponyfill = global.WebStreamsPolyfill || {} |
||||||
|
const isSecureContext = global.isSecureContext |
||||||
|
// TODO: Must come up with a real detection test (#69)
|
||||||
|
let useBlobFallback = /constructor/i.test(global.HTMLElement) || !!global.safari || !!global.WebKitPoint |
||||||
|
const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style |
||||||
|
? 'iframe' |
||||||
|
: 'navigate' |
||||||
|
|
||||||
|
const streamSaver = { |
||||||
|
createWriteStream, |
||||||
|
WritableStream: global.WritableStream || ponyfill.WritableStream, |
||||||
|
supported: true, |
||||||
|
version: { full: '2.0.5', major: 2, minor: 0, dot: 5 }, |
||||||
|
mitm: 'https://jimmywarting.github.io/StreamSaver.js/mitm.html?version=2.0.0' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* create a hidden iframe and append it to the DOM (body) |
||||||
|
* |
||||||
|
* @param {string} src page to load |
||||||
|
* @return {HTMLIFrameElement} page to load |
||||||
|
*/ |
||||||
|
function makeIframe (src) { |
||||||
|
if (!src) throw new Error('meh') |
||||||
|
const iframe = document.createElement('iframe') |
||||||
|
iframe.hidden = true |
||||||
|
iframe.src = src |
||||||
|
iframe.loaded = false |
||||||
|
iframe.name = 'iframe' |
||||||
|
iframe.isIframe = true |
||||||
|
iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) |
||||||
|
iframe.addEventListener('load', () => { |
||||||
|
iframe.loaded = true |
||||||
|
}, { once: true }) |
||||||
|
document.body.appendChild(iframe) |
||||||
|
return iframe |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* create a popup that simulates the basic things |
||||||
|
* of what a iframe can do |
||||||
|
* |
||||||
|
* @param {string} src page to load |
||||||
|
* @return {object} iframe like object |
||||||
|
*/ |
||||||
|
function makePopup (src) { |
||||||
|
const options = 'width=200,height=100' |
||||||
|
const delegate = document.createDocumentFragment() |
||||||
|
const popup = { |
||||||
|
frame: global.open(src, 'popup', options), |
||||||
|
loaded: false, |
||||||
|
isIframe: false, |
||||||
|
isPopup: true, |
||||||
|
remove () { popup.frame.close() }, |
||||||
|
addEventListener (...args) { delegate.addEventListener(...args) }, |
||||||
|
dispatchEvent (...args) { delegate.dispatchEvent(...args) }, |
||||||
|
removeEventListener (...args) { delegate.removeEventListener(...args) }, |
||||||
|
postMessage (...args) { popup.frame.postMessage(...args) } |
||||||
|
} |
||||||
|
|
||||||
|
const onReady = evt => { |
||||||
|
if (evt.source === popup.frame) { |
||||||
|
popup.loaded = true |
||||||
|
global.removeEventListener('message', onReady) |
||||||
|
popup.dispatchEvent(new Event('load')) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
global.addEventListener('message', onReady) |
||||||
|
|
||||||
|
return popup |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// We can't look for service worker since it may still work on http
|
||||||
|
new Response(new ReadableStream()) |
||||||
|
if (isSecureContext && !('serviceWorker' in navigator)) { |
||||||
|
useBlobFallback = true |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
useBlobFallback = true |
||||||
|
} |
||||||
|
|
||||||
|
test(() => { |
||||||
|
// Transferable stream was first enabled in chrome v73 behind a flag
|
||||||
|
const { readable } = new TransformStream() |
||||||
|
const mc = new MessageChannel() |
||||||
|
mc.port1.postMessage(readable, [readable]) |
||||||
|
mc.port1.close() |
||||||
|
mc.port2.close() |
||||||
|
supportsTransferable = true |
||||||
|
// Freeze TransformStream object (can only work with native)
|
||||||
|
Object.defineProperty(streamSaver, 'TransformStream', { |
||||||
|
configurable: false, |
||||||
|
writable: false, |
||||||
|
value: TransformStream |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
function loadTransporter () { |
||||||
|
if (!mitmTransporter) { |
||||||
|
mitmTransporter = isSecureContext |
||||||
|
? makeIframe(streamSaver.mitm) |
||||||
|
: makePopup(streamSaver.mitm) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} filename filename that should be used |
||||||
|
* @param {object} options [description] |
||||||
|
* @param {number} size deprecated |
||||||
|
* @return {WritableStream<Uint8Array>} |
||||||
|
*/ |
||||||
|
function createWriteStream (filename, options, size) { |
||||||
|
let opts = { |
||||||
|
size: null, |
||||||
|
pathname: null, |
||||||
|
writableStrategy: undefined, |
||||||
|
readableStrategy: undefined |
||||||
|
} |
||||||
|
|
||||||
|
let bytesWritten = 0 // by StreamSaver.js (not the service worker)
|
||||||
|
let downloadUrl = null |
||||||
|
let channel = null |
||||||
|
let ts = null |
||||||
|
|
||||||
|
// normalize arguments
|
||||||
|
if (Number.isFinite(options)) { |
||||||
|
[ size, options ] = [ options, size ] |
||||||
|
console.warn('[StreamSaver] Deprecated pass an object as 2nd argument when creating a write stream') |
||||||
|
opts.size = size |
||||||
|
opts.writableStrategy = options |
||||||
|
} else if (options && options.highWaterMark) { |
||||||
|
console.warn('[StreamSaver] Deprecated pass an object as 2nd argument when creating a write stream') |
||||||
|
opts.size = size |
||||||
|
opts.writableStrategy = options |
||||||
|
} else { |
||||||
|
opts = options || {} |
||||||
|
} |
||||||
|
if (!useBlobFallback) { |
||||||
|
loadTransporter() |
||||||
|
|
||||||
|
channel = new MessageChannel() |
||||||
|
|
||||||
|
// Make filename RFC5987 compatible
|
||||||
|
filename = encodeURIComponent(filename.replace(/\//g, ':')) |
||||||
|
.replace(/['()]/g, escape) |
||||||
|
.replace(/\*/g, '%2A') |
||||||
|
|
||||||
|
const response = { |
||||||
|
transferringReadable: supportsTransferable, |
||||||
|
pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/octet-stream; charset=utf-8', |
||||||
|
'Content-Disposition': "attachment; filename*=UTF-8''" + filename |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (opts.size) { |
||||||
|
response.headers['Content-Length'] = opts.size |
||||||
|
} |
||||||
|
|
||||||
|
const args = [ response, '*', [ channel.port2 ] ] |
||||||
|
|
||||||
|
if (supportsTransferable) { |
||||||
|
const transformer = downloadStrategy === 'iframe' ? undefined : { |
||||||
|
// This transformer & flush method is only used by insecure context.
|
||||||
|
transform (chunk, controller) { |
||||||
|
if (!(chunk instanceof Uint8Array)) { |
||||||
|
throw new TypeError('Can only write Uint8Arrays') |
||||||
|
} |
||||||
|
bytesWritten += chunk.length |
||||||
|
controller.enqueue(chunk) |
||||||
|
|
||||||
|
if (downloadUrl) { |
||||||
|
location.href = downloadUrl |
||||||
|
downloadUrl = null |
||||||
|
} |
||||||
|
}, |
||||||
|
flush () { |
||||||
|
if (downloadUrl) { |
||||||
|
location.href = downloadUrl |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
ts = new streamSaver.TransformStream( |
||||||
|
transformer, |
||||||
|
opts.writableStrategy, |
||||||
|
opts.readableStrategy |
||||||
|
) |
||||||
|
const readableStream = ts.readable |
||||||
|
|
||||||
|
channel.port1.postMessage({ readableStream }, [ readableStream ]) |
||||||
|
} |
||||||
|
|
||||||
|
channel.port1.onmessage = evt => { |
||||||
|
// Service worker sent us a link that we should open.
|
||||||
|
if (evt.data.download) { |
||||||
|
// Special treatment for popup...
|
||||||
|
if (downloadStrategy === 'navigate') { |
||||||
|
mitmTransporter.remove() |
||||||
|
mitmTransporter = null |
||||||
|
if (bytesWritten) { |
||||||
|
location.href = evt.data.download |
||||||
|
} else { |
||||||
|
downloadUrl = evt.data.download |
||||||
|
} |
||||||
|
} else { |
||||||
|
if (mitmTransporter.isPopup) { |
||||||
|
mitmTransporter.remove() |
||||||
|
mitmTransporter = null |
||||||
|
// Special case for firefox, they can keep sw alive with fetch
|
||||||
|
if (downloadStrategy === 'iframe') { |
||||||
|
makeIframe(streamSaver.mitm) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// We never remove this iframes b/c it can interrupt saving
|
||||||
|
makeIframe(evt.data.download) |
||||||
|
} |
||||||
|
} else if (evt.data.abort) { |
||||||
|
chunks = [] |
||||||
|
channel.port1.postMessage('abort') //send back so controller is aborted
|
||||||
|
channel.port1.onmessage = null |
||||||
|
channel.port1.close() |
||||||
|
channel.port2.close() |
||||||
|
channel = null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (mitmTransporter.loaded) { |
||||||
|
mitmTransporter.postMessage(...args) |
||||||
|
} else { |
||||||
|
mitmTransporter.addEventListener('load', () => { |
||||||
|
mitmTransporter.postMessage(...args) |
||||||
|
}, { once: true }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let chunks = [] |
||||||
|
|
||||||
|
return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ |
||||||
|
write (chunk) { |
||||||
|
if (!(chunk instanceof Uint8Array)) { |
||||||
|
throw new TypeError('Can only write Uint8Arrays') |
||||||
|
} |
||||||
|
if (useBlobFallback) { |
||||||
|
// Safari... The new IE6
|
||||||
|
// https://github.com/jimmywarting/StreamSaver.js/issues/69
|
||||||
|
//
|
||||||
|
// even though it has everything it fails to download anything
|
||||||
|
// that comes from the service worker..!
|
||||||
|
chunks.push(chunk) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// is called when a new chunk of data is ready to be written
|
||||||
|
// to the underlying sink. It can return a promise to signal
|
||||||
|
// success or failure of the write operation. The stream
|
||||||
|
// implementation guarantees that this method will be called
|
||||||
|
// only after previous writes have succeeded, and never after
|
||||||
|
// close or abort is called.
|
||||||
|
|
||||||
|
// TODO: Kind of important that service worker respond back when
|
||||||
|
// it has been written. Otherwise we can't handle backpressure
|
||||||
|
// EDIT: Transferable streams solves this...
|
||||||
|
channel.port1.postMessage(chunk) |
||||||
|
bytesWritten += chunk.length |
||||||
|
|
||||||
|
if (downloadUrl) { |
||||||
|
location.href = downloadUrl |
||||||
|
downloadUrl = null |
||||||
|
} |
||||||
|
}, |
||||||
|
close () { |
||||||
|
if (useBlobFallback) { |
||||||
|
const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) |
||||||
|
const link = document.createElement('a') |
||||||
|
link.href = URL.createObjectURL(blob) |
||||||
|
link.download = filename |
||||||
|
link.click() |
||||||
|
} else { |
||||||
|
channel.port1.postMessage('end') |
||||||
|
} |
||||||
|
}, |
||||||
|
abort () { |
||||||
|
chunks = [] |
||||||
|
channel.port1.postMessage('abort') |
||||||
|
channel.port1.onmessage = null |
||||||
|
channel.port1.close() |
||||||
|
channel.port2.close() |
||||||
|
channel = null |
||||||
|
} |
||||||
|
}, opts.writableStrategy) |
||||||
|
} |
||||||
|
|
||||||
|
return streamSaver |
||||||
|
}) |
@ -0,0 +1,129 @@ |
|||||||
|
/* global self ReadableStream Response */ |
||||||
|
|
||||||
|
self.addEventListener('install', () => { |
||||||
|
self.skipWaiting() |
||||||
|
}) |
||||||
|
|
||||||
|
self.addEventListener('activate', event => { |
||||||
|
event.waitUntil(self.clients.claim()) |
||||||
|
}) |
||||||
|
|
||||||
|
const map = new Map() |
||||||
|
|
||||||
|
// This should be called once per download
|
||||||
|
// Each event has a dataChannel that the data will be piped through
|
||||||
|
self.onmessage = event => { |
||||||
|
// We send a heartbeat every x second to keep the
|
||||||
|
// service worker alive if a transferable stream is not sent
|
||||||
|
if (event.data === 'ping') { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const data = event.data |
||||||
|
const downloadUrl = data.url || self.registration.scope + Math.random() + '/' + (typeof data === 'string' ? data : data.filename) |
||||||
|
const port = event.ports[0] |
||||||
|
const metadata = new Array(3) // [stream, data, port]
|
||||||
|
|
||||||
|
metadata[1] = data |
||||||
|
metadata[2] = port |
||||||
|
|
||||||
|
// Note to self:
|
||||||
|
// old streamsaver v1.2.0 might still use `readableStream`...
|
||||||
|
// but v2.0.0 will always transfer the stream through MessageChannel #94
|
||||||
|
if (event.data.readableStream) { |
||||||
|
metadata[0] = event.data.readableStream |
||||||
|
} else if (event.data.transferringReadable) { |
||||||
|
port.onmessage = evt => { |
||||||
|
port.onmessage = null |
||||||
|
metadata[0] = evt.data.readableStream |
||||||
|
} |
||||||
|
} else { |
||||||
|
metadata[0] = createStream(port) |
||||||
|
} |
||||||
|
|
||||||
|
map.set(downloadUrl, metadata) |
||||||
|
port.postMessage({ download: downloadUrl }) |
||||||
|
} |
||||||
|
|
||||||
|
function createStream (port) { |
||||||
|
// ReadableStream is only supported by chrome 52
|
||||||
|
return new ReadableStream({ |
||||||
|
start (controller) { |
||||||
|
// When we receive data on the messageChannel, we write
|
||||||
|
port.onmessage = ({ data }) => { |
||||||
|
if (data === 'end') { |
||||||
|
return controller.close() |
||||||
|
} |
||||||
|
|
||||||
|
if (data === 'abort') { |
||||||
|
controller.error('Aborted the download') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
controller.enqueue(data) |
||||||
|
} |
||||||
|
}, |
||||||
|
cancel (reason) { |
||||||
|
console.log('user aborted', reason) |
||||||
|
port.postMessage({ abort: true }) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
self.onfetch = event => { |
||||||
|
const url = event.request.url |
||||||
|
|
||||||
|
// this only works for Firefox
|
||||||
|
if (url.endsWith('/ping')) { |
||||||
|
return event.respondWith(new Response('pong')) |
||||||
|
} |
||||||
|
|
||||||
|
const hijacke = map.get(url) |
||||||
|
|
||||||
|
if (!hijacke) return null |
||||||
|
|
||||||
|
const [ stream, data, port ] = hijacke |
||||||
|
|
||||||
|
map.delete(url) |
||||||
|
|
||||||
|
// Not comfortable letting any user control all headers
|
||||||
|
// so we only copy over the length & disposition
|
||||||
|
const responseHeaders = new Headers({ |
||||||
|
'Content-Type': 'application/octet-stream; charset=utf-8', |
||||||
|
|
||||||
|
// To be on the safe side, The link can be opened in a iframe.
|
||||||
|
// but octet-stream should stop it.
|
||||||
|
'Content-Security-Policy': "default-src 'none'", |
||||||
|
'X-Content-Security-Policy': "default-src 'none'", |
||||||
|
'X-WebKit-CSP': "default-src 'none'", |
||||||
|
'X-XSS-Protection': '1; mode=block' |
||||||
|
}) |
||||||
|
|
||||||
|
let headers = new Headers(data.headers || {}) |
||||||
|
|
||||||
|
if (headers.has('Content-Length')) { |
||||||
|
responseHeaders.set('Content-Length', headers.get('Content-Length')) |
||||||
|
} |
||||||
|
|
||||||
|
if (headers.has('Content-Disposition')) { |
||||||
|
responseHeaders.set('Content-Disposition', headers.get('Content-Disposition')) |
||||||
|
} |
||||||
|
|
||||||
|
// data, data.filename and size should not be used anymore
|
||||||
|
if (data.size) { |
||||||
|
console.warn('Depricated') |
||||||
|
responseHeaders.set('Content-Length', data.size) |
||||||
|
} |
||||||
|
|
||||||
|
let fileName = typeof data === 'string' ? data : data.filename |
||||||
|
if (fileName) { |
||||||
|
console.warn('Depricated') |
||||||
|
// Make filename RFC5987 compatible
|
||||||
|
fileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, '%2A') |
||||||
|
responseHeaders.set('Content-Disposition', "attachment; filename*=UTF-8''" + fileName) |
||||||
|
} |
||||||
|
|
||||||
|
event.respondWith(new Response(stream, { headers: responseHeaders })) |
||||||
|
|
||||||
|
port.postMessage({ debug: 'Download started' }) |
||||||
|
} |
Loading…
Reference in new issue