解决老旧浏览器(火狐)流式下载问题
This commit is contained in:
parent
3b9c58f4d9
commit
bb7b36b5bd
@ -52,6 +52,7 @@
|
|||||||
"echarts": "5.6.0",
|
"echarts": "5.6.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
|
"web-streams-polyfill": "^3.2.1",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"naive-ui": "2.42.0",
|
"naive-ui": "2.42.0",
|
||||||
|
@ -92,6 +92,9 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: 4.5.1
|
specifier: 4.5.1
|
||||||
version: 4.5.1(vue@3.5.17(typescript@5.8.3))
|
version: 4.5.1(vue@3.5.17(typescript@5.8.3))
|
||||||
|
web-streams-polyfill:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.3.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@elegant-router/vue':
|
'@elegant-router/vue':
|
||||||
specifier: 0.3.8
|
specifier: 0.3.8
|
||||||
@ -4098,6 +4101,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.11
|
vue: ^3.0.11
|
||||||
|
|
||||||
|
web-streams-polyfill@3.3.3:
|
||||||
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
webpack-sources@3.3.3:
|
webpack-sources@3.3.3:
|
||||||
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
|
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@ -8218,6 +8225,8 @@ snapshots:
|
|||||||
vooks: 0.2.12(vue@3.5.17(typescript@5.8.3))
|
vooks: 0.2.12(vue@3.5.17(typescript@5.8.3))
|
||||||
vue: 3.5.17(typescript@5.8.3)
|
vue: 3.5.17(typescript@5.8.3)
|
||||||
|
|
||||||
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
webpack-sources@3.3.3: {}
|
webpack-sources@3.3.3: {}
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
166
cds-fontend-2025.V1/public/mitm.html
Normal file
166
cds-fontend-2025.V1/public/mitm.html
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<!--
|
||||||
|
mitm.html is the lite "man in the middle"
|
||||||
|
|
||||||
|
This is only meant to signal the opener's messageChannel to
|
||||||
|
the service worker - when that is done this mitm can be closed
|
||||||
|
but it's better to keep it alive since this also stops the sw
|
||||||
|
from restarting
|
||||||
|
|
||||||
|
The service worker is capable of intercepting all request and fork their
|
||||||
|
own "fake" response - wish we are going to craft
|
||||||
|
when the worker then receives a stream then the worker will tell the opener
|
||||||
|
to open up a link that will start the download
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
// This will prevent the sw from restarting
|
||||||
|
let keepAlive = () => {
|
||||||
|
keepAlive = () => {}
|
||||||
|
var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'
|
||||||
|
var interval = setInterval(() => {
|
||||||
|
if (sw) {
|
||||||
|
sw.postMessage('ping')
|
||||||
|
} else {
|
||||||
|
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// message event is the first thing we need to setup a listner for
|
||||||
|
// don't want the opener to do a random timeout - instead they can listen for
|
||||||
|
// the ready event
|
||||||
|
// but since we need to wait for the Service Worker registration, we store the
|
||||||
|
// message for later
|
||||||
|
let messages = []
|
||||||
|
window.onmessage = evt => messages.push(evt)
|
||||||
|
|
||||||
|
let sw = null
|
||||||
|
let scope = ''
|
||||||
|
|
||||||
|
function registerWorker() {
|
||||||
|
return navigator.serviceWorker.getRegistration('./').then(swReg => {
|
||||||
|
return swReg || navigator.serviceWorker.register('sw.js', { scope: './' })
|
||||||
|
}).then(swReg => {
|
||||||
|
const swRegTmp = swReg.installing || swReg.waiting
|
||||||
|
|
||||||
|
scope = swReg.scope
|
||||||
|
|
||||||
|
return (sw = swReg.active) || new Promise(resolve => {
|
||||||
|
swRegTmp.addEventListener('statechange', fn = () => {
|
||||||
|
if (swRegTmp.state === 'activated') {
|
||||||
|
swRegTmp.removeEventListener('statechange', fn)
|
||||||
|
sw = swReg.active
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we have the Service Worker registered we can process messages
|
||||||
|
function onMessage (event) {
|
||||||
|
let { data, ports, origin } = event
|
||||||
|
|
||||||
|
// It's important to have a messageChannel, don't want to interfere
|
||||||
|
// with other simultaneous downloads
|
||||||
|
if (!ports || !ports.length) {
|
||||||
|
throw new TypeError("[StreamSaver] You didn't send a messageChannel")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data !== 'object') {
|
||||||
|
throw new TypeError("[StreamSaver] You didn't send a object")
|
||||||
|
}
|
||||||
|
|
||||||
|
// the default public service worker for StreamSaver is shared among others.
|
||||||
|
// so all download links needs to be prefixed to avoid any other conflict
|
||||||
|
data.origin = origin
|
||||||
|
|
||||||
|
// if we ever (in some feature versoin of streamsaver) would like to
|
||||||
|
// redirect back to the page of who initiated a http request
|
||||||
|
data.referrer = data.referrer || document.referrer || origin
|
||||||
|
|
||||||
|
// pass along version for possible backwards compatibility in sw.js
|
||||||
|
data.streamSaverVersion = new URLSearchParams(location.search).get('version')
|
||||||
|
|
||||||
|
if (data.streamSaverVersion === '1.2.0') {
|
||||||
|
console.warn('[StreamSaver] please update streamsaver')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @since v2.0.0 */
|
||||||
|
if (!data.headers) {
|
||||||
|
console.warn("[StreamSaver] pass `data.headers` that you would like to pass along to the service worker\nit should be a 2D array or a key/val object that fetch's Headers api accepts")
|
||||||
|
} else {
|
||||||
|
// test if it's correct
|
||||||
|
// should thorw a typeError if not
|
||||||
|
new Headers(data.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @since v2.0.0 */
|
||||||
|
if (typeof data.filename === 'string') {
|
||||||
|
console.warn("[StreamSaver] You shouldn't send `data.filename` anymore. It should be included in the Content-Disposition header option")
|
||||||
|
// Do what File constructor do with fileNames
|
||||||
|
data.filename = data.filename.replace(/\//g, ':')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @since v2.0.0 */
|
||||||
|
if (data.size) {
|
||||||
|
console.warn("[StreamSaver] You shouldn't send `data.size` anymore. It should be included in the content-length header option")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @since v2.0.0 */
|
||||||
|
if (data.readableStream) {
|
||||||
|
console.warn("[StreamSaver] You should send the readableStream in the messageChannel, not throught mitm")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @since v2.0.0 */
|
||||||
|
if (!data.pathname) {
|
||||||
|
console.warn("[StreamSaver] Please send `data.pathname` (eg: /pictures/summer.jpg)")
|
||||||
|
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove all leading slashes
|
||||||
|
data.pathname = data.pathname.replace(/^\/+/g, '')
|
||||||
|
|
||||||
|
// remove protocol
|
||||||
|
let org = origin.replace(/(^\w+:|^)\/\//, '')
|
||||||
|
|
||||||
|
// set the absolute pathname to the download url.
|
||||||
|
data.url = new URL(`${scope + org}/${data.pathname}`).toString()
|
||||||
|
|
||||||
|
if (!data.url.startsWith(`${scope + org}/`)) {
|
||||||
|
throw new TypeError('[StreamSaver] bad `data.pathname`')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sends the message data as well as transferring
|
||||||
|
// messageChannel.port2 to the service worker. The service worker can
|
||||||
|
// then use the transferred port to reply via postMessage(), which
|
||||||
|
// will in turn trigger the onmessage handler on messageChannel.port1.
|
||||||
|
|
||||||
|
const transferable = data.readableStream
|
||||||
|
? [ ports[0], data.readableStream ]
|
||||||
|
: [ ports[0] ]
|
||||||
|
|
||||||
|
if (!(data.readableStream || data.transferringReadable)) {
|
||||||
|
keepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
return sw.postMessage(data, transferable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.opener) {
|
||||||
|
// The opener can't listen to onload event, so we need to help em out!
|
||||||
|
// (telling them that we are ready to accept postMessage's)
|
||||||
|
window.opener.postMessage('StreamSaver::loadedPopup', '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.serviceWorker) {
|
||||||
|
registerWorker().then(() => {
|
||||||
|
window.onmessage = onMessage
|
||||||
|
messages.forEach(window.onmessage)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// FF can ping sw with fetch from a secure hidden iframe
|
||||||
|
// shouldn't really be possible?
|
||||||
|
keepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
130
cds-fontend-2025.V1/public/sw.js
Normal file
130
cds-fontend-2025.V1/public/sw.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/* 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',
|
||||||
|
'Cross-Origin-Embedder-Policy': 'require-corp'
|
||||||
|
})
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { errorCodeRecord } from '@/constants/common';
|
|||||||
import { localStg } from '@/utils/storage';
|
import { localStg } from '@/utils/storage';
|
||||||
import { getServiceBaseURL } from '@/utils/service';
|
import { getServiceBaseURL } from '@/utils/service';
|
||||||
import { transformToURLSearchParams } from '@/utils/common';
|
import { transformToURLSearchParams } from '@/utils/common';
|
||||||
|
import { WritableStream } from 'web-streams-polyfill/ponyfill';
|
||||||
|
|
||||||
interface RequestConfig {
|
interface RequestConfig {
|
||||||
method: 'GET' | 'POST';
|
method: 'GET' | 'POST';
|
||||||
@ -51,6 +52,15 @@ export function useDownload() {
|
|||||||
contentLength?: number
|
contentLength?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
window.$loading?.endLoading();
|
window.$loading?.endLoading();
|
||||||
|
|
||||||
|
// 检查浏览器是否原生支持 WritableStream,若不支持则使用polyfill
|
||||||
|
if (typeof window.WritableStream === 'undefined') {
|
||||||
|
window.WritableStream = WritableStream;
|
||||||
|
// 确保StreamSaver也使用相同的Polyfill
|
||||||
|
StreamSaver.WritableStream = WritableStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSaver.mitm = '/mitm.html' // 增加此行代码
|
||||||
const fileStream = StreamSaver.createWriteStream(filename, { size: contentLength });
|
const fileStream = StreamSaver.createWriteStream(filename, { size: contentLength });
|
||||||
|
|
||||||
if (window.WritableStream && readableStream?.pipeTo) {
|
if (window.WritableStream && readableStream?.pipeTo) {
|
||||||
|
Loading…
Reference in New Issue
Block a user