From 648190bf65bc83527c3d9faf359346359eb4bd92 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 24 Oct 2025 14:19:02 -0700 Subject: [PATCH] [backport rh-test] add service worker on cloud distribution to attach auth header to browser native `/view` requests (#6139) (#6259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backport of #6139 to `rh-test` branch. Added Service Worker to inject Firebase auth headers into browser-native `/api/view` requests (img, video, audio tags) for cloud distribution. ## Changes - **What**: Implemented [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) to intercept and authenticate media requests that cannot natively send custom headers - **Dependencies**: None (uses native Service Worker API) ## Implementation Details **Tree-shaking**: Uses compile-time `isCloud` constant - completely removed from localhost/desktop builds (verified via bundle analysis). **Caching**: 50-minute auth header cache with automatic invalidation on login/logout to prevent redundant token fetches. ## Backport Notes - Resolved merge conflict in `src/main.ts` where remote config loading logic was added on `rh-test` - Preserved the CRITICAL comment about loading remote config first - All files from original commit included - Typecheck passed successfully Original commit: 26f587c9560691b846807203ae8f3c8e481d21b8 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6259-backport-rh-test-add-service-worker-on-cloud-distribution-to-attach-auth-header-to-brow-2966d73d365081b39cdac969b6c24d0d) by [Unito](https://www.unito.io) --- eslint.config.ts | 1 + knip.config.ts | 4 +- public/auth-sw.js | 147 ++++++++++++++++++++ src/main.ts | 9 +- src/platform/auth/serviceWorker/index.ts | 9 ++ src/platform/auth/serviceWorker/register.ts | 57 ++++++++ 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 public/auth-sw.js create mode 100644 src/platform/auth/serviceWorker/index.ts create mode 100644 src/platform/auth/serviceWorker/register.ts diff --git a/eslint.config.ts b/eslint.config.ts index c64ae14ab..cdeb7460e 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -52,6 +52,7 @@ export default defineConfig([ '**/vite.config.*.timestamp*', '**/vitest.config.*.timestamp*', 'packages/registry-types/src/comfyRegistryTypes.ts', + 'public/auth-sw.js', 'src/extensions/core/*', 'src/scripts/*', 'src/types/generatedManagerTypes.ts', diff --git a/knip.config.ts b/knip.config.ts index 30fa9687e..593007446 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -44,7 +44,9 @@ const config: KnipConfig = { 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', 'packages/registry-types/src/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) - 'src/scripts/ui/components/splitButton.ts' + 'src/scripts/ui/components/splitButton.ts', + // Service worker - registered at runtime via navigator.serviceWorker.register() + 'public/auth-sw.js' ], compilers: { // https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199 diff --git a/public/auth-sw.js b/public/auth-sw.js new file mode 100644 index 000000000..60a11eff1 --- /dev/null +++ b/public/auth-sw.js @@ -0,0 +1,147 @@ +/** + * @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|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) + } + + return fetch( + new Request(event.request.url, { + method: event.request.method, + headers: headers, + mode: 'same-origin', + credentials: event.request.credentials, + cache: 'no-store', + redirect: event.request.redirect, + referrer: event.request.referrer, + integrity: event.request.integrity + }) + ) + } 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} + */ +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} + */ +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()) +}) diff --git a/src/main.ts b/src/main.ts index ab14d3e8b..979b32d26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,10 +13,7 @@ import { VueFire, VueFireAuth } from 'vuefire' import { FIREBASE_CONFIG } from '@/config/firebase' import '@/lib/litegraph/public/css/litegraph.css' -/** - * CRITICAL: Load remote config FIRST for cloud builds to ensure - * window.__CONFIG__is available for all modules during initialization - */ +import '@/platform/auth/serviceWorker' import { isCloud } from '@/platform/distribution/types' import router from '@/router' @@ -25,6 +22,10 @@ import App from './App.vue' import './assets/css/style.css' import { i18n } from './i18n' +/** + * CRITICAL: Load remote config FIRST for cloud builds to ensure + * window.__CONFIG__is available for all modules during initialization + */ if (isCloud) { const { loadRemoteConfig } = await import( '@/platform/remoteConfig/remoteConfig' diff --git a/src/platform/auth/serviceWorker/index.ts b/src/platform/auth/serviceWorker/index.ts new file mode 100644 index 000000000..c83e238d5 --- /dev/null +++ b/src/platform/auth/serviceWorker/index.ts @@ -0,0 +1,9 @@ +import { isCloud } from '@/platform/distribution/types' + +/** + * Auth service worker registration (cloud-only). + * Tree-shaken for desktop/localhost builds via compile-time constant. + */ +if (isCloud) { + void import('./register') +} diff --git a/src/platform/auth/serviceWorker/register.ts b/src/platform/auth/serviceWorker/register.ts new file mode 100644 index 000000000..eda954dba --- /dev/null +++ b/src/platform/auth/serviceWorker/register.ts @@ -0,0 +1,57 @@ +import { watch } from 'vue' + +import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' + +/** + * Registers the authentication service worker for cloud distribution. + * Intercepts /api/view requests to add auth headers for browser-native requests. + */ +async function registerAuthServiceWorker(): Promise { + if (!('serviceWorker' in navigator)) { + return + } + + try { + await navigator.serviceWorker.register('/auth-sw.js') + + setupAuthHeaderProvider() + setupCacheInvalidation() + } catch (error) { + console.error('[Auth SW] Registration failed:', error) + } +} + +/** + * Listens for auth header requests from the service worker + */ +function setupAuthHeaderProvider(): void { + navigator.serviceWorker.addEventListener('message', async (event) => { + if (event.data.type === 'REQUEST_AUTH_HEADER') { + const firebaseAuthStore = useFirebaseAuthStore() + const authHeader = await firebaseAuthStore.getAuthHeader() + + event.ports[0].postMessage({ + type: 'AUTH_HEADER_RESPONSE', + authHeader + }) + } + }) +} + +/** + * Invalidates cached auth header when user logs in/out + */ +function setupCacheInvalidation(): void { + const { isLoggedIn } = useCurrentUser() + + watch(isLoggedIn, (newValue, oldValue) => { + if (newValue !== oldValue) { + navigator.serviceWorker.controller?.postMessage({ + type: 'INVALIDATE_AUTH_HEADER' + }) + } + }) +} + +void registerAuthServiceWorker()