mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 23:50:00 +00:00
Fixes two related issues with the auth service worker: 1. Opaqueredirect network error: - Service workers cannot return opaqueredirect responses to the page - When using redirect: 'manual', cross-origin redirects produce opaqueredirect - Solution: Re-fetch with redirect: 'follow' when opaqueredirect detected - Browser automatically strips auth headers on cross-origin redirects to GCS 2. Race condition on first load: - App was mounting before service worker registration completed - Images could load before SW gained control of the page - Solution: Await SW registration before mounting app - Changed void import() to await import() at all levels This fixes the pattern where hard refresh works (bypasses SW) but normal refresh fails (SW intercepts but returns invalid opaqueredirect response).
179 lines
4.7 KiB
JavaScript
179 lines
4.7 KiB
JavaScript
/**
|
|
* @fileoverview Authentication Service Worker
|
|
* Intercepts /api/view requests and adds Firebase authentication headers.
|
|
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} AuthHeader
|
|
* @property {string} Authorization - Bearer token for authentication
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} CachedAuth
|
|
* @property {AuthHeader|null} header
|
|
* @property {number} expiresAt - Timestamp when cache expires
|
|
*/
|
|
|
|
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
|
|
|
|
/** @type {CachedAuth|null} */
|
|
let authCache = null
|
|
|
|
/** @type {Promise<AuthHeader|null>|null} */
|
|
let authRequestInFlight = null
|
|
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
|
|
authCache = null
|
|
authRequestInFlight = null
|
|
}
|
|
})
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url)
|
|
|
|
if (
|
|
!url.pathname.startsWith('/api/view') &&
|
|
!url.pathname.startsWith('/api/viewvideo')
|
|
) {
|
|
return
|
|
}
|
|
|
|
event.respondWith(
|
|
(async () => {
|
|
try {
|
|
const authHeader = await getAuthHeader()
|
|
|
|
if (!authHeader) {
|
|
return fetch(event.request)
|
|
}
|
|
|
|
const headers = new Headers(event.request.headers)
|
|
for (const [key, value] of Object.entries(authHeader)) {
|
|
headers.set(key, value)
|
|
}
|
|
|
|
// Fetch with manual redirect to handle cross-origin redirects (e.g., GCS signed URLs)
|
|
const response = await fetch(
|
|
new Request(event.request.url, {
|
|
method: event.request.method,
|
|
headers: headers,
|
|
credentials: event.request.credentials,
|
|
cache: 'no-store',
|
|
redirect: 'manual',
|
|
referrer: event.request.referrer,
|
|
integrity: event.request.integrity
|
|
})
|
|
)
|
|
|
|
// Handle redirects to external storage (e.g., GCS signed URLs)
|
|
if (response.type === 'opaqueredirect') {
|
|
// Opaqueredirect: redirect occurred but response is opaque (headers not accessible)
|
|
// Re-fetch the original /api/view URL with redirect: 'follow'
|
|
// Browser will:
|
|
// 1. Send auth headers to /api/view (same-origin)
|
|
// 2. Receive 302 redirect to GCS
|
|
// 3. Automatically strip auth headers when following cross-origin redirect
|
|
// 4. Use GCS signed URL authentication instead
|
|
return fetch(event.request.url, {
|
|
method: 'GET',
|
|
headers: headers,
|
|
redirect: 'follow'
|
|
})
|
|
}
|
|
|
|
// Non-opaque redirect (status visible) - shouldn't normally happen with redirect: 'manual'
|
|
// but handle as fallback
|
|
if (response.status === 302 || response.status === 301) {
|
|
const location = response.headers.get('location')
|
|
if (location) {
|
|
// Follow redirect manually - do NOT include auth headers for external URLs
|
|
return fetch(location, {
|
|
method: 'GET',
|
|
redirect: 'follow'
|
|
})
|
|
}
|
|
}
|
|
|
|
return response
|
|
} catch (error) {
|
|
console.error('[Auth SW] Request failed:', error)
|
|
return fetch(event.request)
|
|
}
|
|
})()
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Gets auth header from cache or requests from main thread
|
|
* @returns {Promise<AuthHeader|null>}
|
|
*/
|
|
async function getAuthHeader() {
|
|
// Return cached value if valid
|
|
if (authCache && authCache.expiresAt > Date.now()) {
|
|
return authCache.header
|
|
}
|
|
|
|
// Clear expired cache
|
|
if (authCache) {
|
|
authCache = null
|
|
}
|
|
|
|
// Deduplicate concurrent requests
|
|
if (authRequestInFlight) {
|
|
return authRequestInFlight
|
|
}
|
|
|
|
authRequestInFlight = requestAuthHeaderFromMainThread()
|
|
const header = await authRequestInFlight
|
|
authRequestInFlight = null
|
|
|
|
// Cache the result
|
|
if (header) {
|
|
authCache = {
|
|
header,
|
|
expiresAt: Date.now() + CACHE_TTL_MS
|
|
}
|
|
}
|
|
|
|
return header
|
|
}
|
|
|
|
/**
|
|
* Requests auth header from main thread via MessageChannel
|
|
* @returns {Promise<AuthHeader|null>}
|
|
*/
|
|
async function requestAuthHeaderFromMainThread() {
|
|
const clients = await self.clients.matchAll()
|
|
if (clients.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const messageChannel = new MessageChannel()
|
|
|
|
return new Promise((resolve) => {
|
|
let timeoutId
|
|
|
|
messageChannel.port1.onmessage = (event) => {
|
|
clearTimeout(timeoutId)
|
|
resolve(event.data.authHeader)
|
|
}
|
|
|
|
timeoutId = setTimeout(() => {
|
|
console.error(
|
|
'[Auth SW] Timeout waiting for auth header from main thread'
|
|
)
|
|
resolve(null)
|
|
}, 1000)
|
|
|
|
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
|
|
messageChannel.port2
|
|
])
|
|
})
|
|
}
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(self.clients.claim())
|
|
})
|