Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useRouter } from 'vue-router'

import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
Expand All @@ -18,6 +20,7 @@ import { usdToMicros } from '@/utils/formatUtil'
export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
const toastStore = useToastStore()
const router = isCloud ? useRouter() : undefined
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()

const accessError = ref(false)
Expand Down Expand Up @@ -54,6 +57,15 @@ export const useFirebaseAuthActions = () => {
detail: t('auth.signOut.successDetail'),
life: 5000
})

if (isCloud && router) {
try {
await router.push({ name: 'cloud-login' })
} catch (error) {
// needed for local development until we bring in cloud login pages.
window.location.reload()
}
}
}, reportError)

const sendPasswordReset = wrapWithErrorHandlingAsync(
Expand Down
40 changes: 37 additions & 3 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
createRouter,
createWebHashHistory,
createWebHistory
} from 'vue-router'

import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useUserStore } from '@/stores/userStore'
import { isElectron } from '@/utils/envUtil'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'

import { useUserStore } from './stores/userStore'
import { isElectron } from './utils/envUtil'

const isFileProtocol = window.location.protocol === 'file:'
const basePath = isElectron() ? '/' : window.location.pathname

Expand Down Expand Up @@ -56,4 +60,34 @@ const router = createRouter({
}
})

if (isCloud) {
// Global authentication guard
router.beforeEach(async (_to, _from, next) => {
const authStore = useFirebaseAuthStore()

// Wait for Firebase auth to initialize
// Timeout after 16 seconds
if (!authStore.isInitialized) {
try {
const { isInitialized } = storeToRefs(authStore)
await until(isInitialized).toBe(true, { timeout: 16000 })
} catch (error) {
console.error('Auth initialization failed:', error)
return next({ name: 'cloud-auth-timeout' })
}
}

// Pass authenticated users
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return next()
}

// Show sign-in for unauthenticated users
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: In the future, we will route to the onboarding flow from here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That is what I am thinking. Replace the modal with @viva-jinyi's login and auth pages after history v2.

const dialogService = useDialogService()
const loginSuccess = await dialogService.showSignInDialog()
next(loginSuccess ? undefined : false)
})
}

export default router
151 changes: 129 additions & 22 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { promiseTimeout, until } from '@vueuse/core'
import axios from 'axios'
import { get } from 'es-toolkit/compat'

Expand All @@ -6,6 +7,7 @@ import type {
ModelFile,
ModelFolderInfo
} from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { type WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import type {
Expand Down Expand Up @@ -42,6 +44,8 @@ import type {
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'

interface QueuePromptRequestBody {
Expand Down Expand Up @@ -263,6 +267,13 @@ export class ComfyApi extends EventTarget {
user: string
socket: WebSocket | null = null

/**
* Cache Firebase auth store composable function.
*/
private authStoreComposable:
| (() => ReturnType<typeof useFirebaseAuthStore>)
| null = null

reportedUnknownMessageTypes = new Set<string>()

/**
Expand Down Expand Up @@ -317,25 +328,95 @@ export class ComfyApi extends EventTarget {
return this.api_base + route
}

fetchApi(route: string, options?: RequestInit) {
if (!options) {
options = {}
}
if (!options.headers) {
options.headers = {}
/**
* Gets the Firebase auth store instance using cached composable function.
* Caches the composable function on first call, then reuses it.
* Returns null for non-cloud distributions.
* @returns The Firebase auth store instance, or null if not in cloud
*/
private async getAuthStore(): Promise<ReturnType<
typeof useFirebaseAuthStore
> | null> {
if (isCloud) {
if (!this.authStoreComposable) {
const module = await import('@/stores/firebaseAuthStore')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Preventing circular import or is there a timing issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circular import.

this.authStoreComposable = module.useFirebaseAuthStore
}

return this.authStoreComposable()
}
if (!options.cache) {
options.cache = 'no-cache'

return null
}

/**
* Waits for Firebase auth to be initialized before proceeding.
* Includes 10-second timeout to prevent infinite hanging.
*/
private async waitForAuthInitialization(): Promise<void> {
if (isCloud) {
const authStore = await this.getAuthStore()
if (!authStore) return

if (authStore.isInitialized) return

try {
await Promise.race([
until(authStore.isInitialized),
promiseTimeout(10000)
])
} catch {
console.warn('Firebase auth initialization timeout after 10 seconds')
}
}
}

if (Array.isArray(options.headers)) {
options.headers.push(['Comfy-User', this.user])
} else if (options.headers instanceof Headers) {
options.headers.set('Comfy-User', this.user)
private addHeaderEntry(
headers: HeadersInit,
key: string,
value: string
): void {
if (Array.isArray(headers)) {
headers.push([key, value])
} else if (headers instanceof Headers) {
headers.set(key, value)
} else {
options.headers['Comfy-User'] = this.user
headers[key] = value
}
return fetch(this.apiURL(route), options)
}

async fetchApi(route: string, options?: RequestInit) {
const headers: HeadersInit = options?.headers ?? {}

if (isCloud) {
await this.waitForAuthInitialization()

// Get Firebase JWT token if user is logged in
const getAuthHeaderIfAvailable = async (): Promise<AuthHeader | null> => {
try {
const authStore = await this.getAuthStore()
return authStore ? await authStore.getAuthHeader() : null
} catch (error) {
console.warn('Failed to get auth header:', error)
return null
}
}

const authHeader = await getAuthHeaderIfAvailable()

if (authHeader) {
for (const [key, value] of Object.entries(authHeader)) {
this.addHeaderEntry(headers, key, value)
}
}
}

this.addHeaderEntry(headers, 'Comfy-User', this.user)
return fetch(this.apiURL(route), {
cache: 'no-cache',
...options,
headers
Copy link
Contributor Author

@arjansingh arjansingh Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double check for override test cases

  1. cache > options
  2. headers should keep non-conflicting options.headers
  3. conflicting headers passed directly in never get overridden by options.headers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

})
}

override addEventListener<TEvent extends keyof ApiEvents>(
Expand Down Expand Up @@ -402,19 +483,45 @@ export class ComfyApi extends EventTarget {
* Creates and connects a WebSocket for realtime updates
* @param {boolean} isReconnect If the socket is connection is a reconnect attempt
*/
#createSocket(isReconnect?: boolean) {
private async createSocket(isReconnect?: boolean) {
if (this.socket) {
return
}

let opened = false
let existingSession = window.name

// Get auth token if available
let authToken: string | undefined
if (isCloud) {
try {
const authStore = await this.getAuthStore()
authToken = authStore ? await authStore.getIdToken() : undefined
} catch (error) {
// Continue without auth token if there's an error
console.warn(
'Could not get auth token for WebSocket connection:',
error
)
}
}

// Build WebSocket URL with query parameters
const params = new URLSearchParams()

if (existingSession) {
existingSession = '?clientId=' + existingSession
params.set('clientId', existingSession)
}
this.socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}`
)
if (isCloud && authToken) {
params.set('token', authToken)
}

const protocol = window.location.protocol === 'https:' ? 's' : ''
const baseUrl = `ws${protocol}://${this.api_host}${this.api_base}/ws`
const query = params.toString()
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl

this.socket = new WebSocket(wsUrl)
this.socket.binaryType = 'arraybuffer'

this.socket.addEventListener('open', () => {
Expand All @@ -441,9 +548,9 @@ export class ComfyApi extends EventTarget {
})

this.socket.addEventListener('close', () => {
setTimeout(() => {
setTimeout(async () => {
this.socket = null
this.#createSocket(true)
await this.createSocket(true)
}, 300)
if (opened) {
this.dispatchCustomEvent('status', null)
Expand Down Expand Up @@ -579,7 +686,7 @@ export class ComfyApi extends EventTarget {
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket()
this.createSocket()
}

/**
Expand Down
Loading