witersen
3 years ago
6 changed files with 501 additions and 31 deletions
@ -0,0 +1,317 @@
@@ -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 @@
@@ -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