使用Express代理媒体规避CORS:Chrome等正常但iOS Safari视频加载异常
iOS 版 Safari 对媒体资源的加载逻辑比安卓端浏览器严格得多,你遇到的“视频被当作普通传输流加载、无法正常展示”的问题,本质是代理实现没有满足 iOS Safari 对媒体响应的硬性要求,可按以下步骤排查修复:
核心问题定位
当前的实现直接将源响应流管道传输给客户端,没有做任何响应头透传、请求逻辑兼容,而 Chrome、Firefox 对媒体识别的容错性高,不会触发这类问题;但 Safari 对媒体资源的校验规则非常严格,只要任意一项不满足,就会自动降级为二进制传输流模式,不会触发内置视频播放器的解析逻辑。
具体排查与修复步骤
补全透传关键响应头,修正MIME识别问题
你当前的实现完全没有同步源响应的核心头信息,Express 默认会给无手动设置头的响应标记为application/octet-stream类型,Safari 识别不到合法的视频MIME类型,自然会将其作为普通下载流处理。
需要手动从源响应中提取并透传以下核心头:Content-Type、Content-Length、Accept-Ranges、Content-Range、ETag、Last-Modified;同时要过滤掉会引发冲突的头,比如源站自带的CORS头、Set-Cookie、和管道流不兼容的Content-Encoding头。
如果遇到源站返回的MIME类型本身不规范的情况(比如mp4资源被标记为octet-stream),需要根据资源URL的文件后缀做MIME值兜底映射,强制将响应类型修正为合法的视频MIME值。兼容Safari的Range分段请求逻辑
iOS Safari 加载视频时一定会优先发送带*Range: bytes=0-*的探测请求,校验资源是否支持分段流式加载。如果你的代理直接忽略这个请求头,不将它转发给源站,直接返回全量的200状态码响应,Safari 就会判定该资源不适合流媒体播放,自动降级为纯传输流模式。
正确的处理逻辑是:收到客户端请求时,将请求里的Range头原样转发给源站的fetch请求,拿到源站返回的206分段响应后,把对应的状态码、Range相关的响应头原样透传给客户端,不要统一用200状态码返回全量内容。补充Safari要求的缓存控制头
iOS Safari 对没有明确缓存标识的媒体响应,会强制按临时传输流处理,不会启动播放器解析逻辑。需要手动给响应补充Cache-Control: public, max-age=86400头,明确告知浏览器该媒体资源可被缓存,允许触发播放器逻辑。规避常见实现冲突
检查项目是否在媒体代理路由上挂载了全局压缩中间件,被中间件压缩过的视频流会被Safari识别为无效传输内容,导致播放失败,需要给媒体代理路由豁免压缩规则。另外原实现中的res.statusSend(415)不属于Express Response对象的合法方法,会导致错误捕获分支运行异常,需要替换为标准的res.status(415).send('错误信息')调用格式。
改造后的参考代码
import {Request, Response} from "express"; import * as mime from 'mime-types' const fetch = require('node-fetch'); class MediaController { static getMedia = async (req: Request, res: Response) => { try { // 转发Range请求头,兼容Safari分段加载要求 const fetchHeaders: Record<string, string> = {} if (req.headers.range) { fetchHeaders.range = req.headers.range } const sourceResponse = await fetch(req.query.url as string, { headers: fetchHeaders }) // 透传非冲突类响应头 const skipHeaderPatterns = ['content-encoding', 'set-cookie', 'access-control'] Object.keys(sourceResponse.headers).forEach(key => { const lowerKey = key.toLowerCase() if (!skipHeaderPatterns.some(pattern => lowerKey.includes(pattern))) { res.setHeader(key, sourceResponse.headers.get(key)) } }) // 兜底修正MIME类型 const sourceUrl = new URL(req.query.url as string) const fileExt = sourceUrl.pathname.split('.').pop() || '' const mappedMime = mime.lookup(fileExt) if (mappedMime) { res.setHeader('Content-Type', mappedMime) } // 补全Safari要求的缓存控制头 res.setHeader('Cache-Control', 'public, max-age=86400') // 原样透传源站响应状态(包含206分段响应码) res.status(sourceResponse.status) // 管道传输响应流 sourceResponse.body.pipe(res) } catch (e) { console.log(e) res.status(415).send('Media fetch failed') } } } export default MediaController;
内容的提问来源于stack exchange,提问作者yulof

