feat(api): add history_v2 for cloud outputs (#6288)

## Summary

Backport outputs from new cloud history endpoint

Does:
1. Show history in the Queue
2. Show outputs from prompt execution

Does not:
1. Handle appending latest images generated to queue history
2. Making sure that workflow data from images is available from load
(requires additional API call to fetch)

Most of this PR is:
1. Test fixtures (truncated workflow to test).
2. The service worker so I could verify my changes locally.

## Changes

- Add `history_v2` to `history` adapter
- Add tests for mapping
- Do branded validation for promptIds (suggestion from @DrJKL)
- Create a dev environment service worker so we can view cloud hosted
images in development.

## Review Focus

1. Is the dev-only service work the right way to do it? It was the
easiest I could think of.
4. Are the validation changes too heavy? I can rip them out if needed.

## Screenshots 🎃 


https://github.com/user-attachments/assets/1787485a-8d27-4abe-abc8-cf133c1a52aa

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6288-Feat-history-v2-outputs-2976d73d365081a99864c40343449dcd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
Arjan Singh
2025-10-25 22:16:38 -07:00
committed by GitHub
parent e0e6d15cbb
commit c67c93ff4b
22 changed files with 1013 additions and 22 deletions

168
public/auth-dev-sw.js Normal file
View File

@@ -0,0 +1,168 @@
/**
* @fileoverview Authentication Service Worker (Development Version)
* Intercepts /api/view requests and rewrites them to a configurable base URL with auth token.
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
* This version is used in development to proxy requests to staging/test environments.
* Default base URL: https://testcloud.comfy.org (configurable via SET_BASE_URL message)
*/
/**
* @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
/** @type {string} */
let baseUrl = 'https://testcloud.comfy.org'
self.addEventListener('message', (event) => {
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
authCache = null
authRequestInFlight = null
}
if (event.data.type === 'SET_BASE_URL') {
baseUrl = event.data.baseUrl
console.log('[Auth DEV SW] Base URL set to:', baseUrl)
}
})
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 {
// Rewrite URL to use configured base URL (default: stagingcloud.comfy.org)
const originalUrl = new URL(event.request.url)
const rewrittenUrl = new URL(
originalUrl.pathname + originalUrl.search,
baseUrl
)
const authHeader = await getAuthHeader()
// With mode: 'no-cors', Authorization headers are stripped by the browser
// So we add the token to the URL as a query parameter instead
if (authHeader && authHeader.Authorization) {
const token = authHeader.Authorization.replace('Bearer ', '')
rewrittenUrl.searchParams.set('token', token)
}
// Cross-origin request requires no-cors mode
// - mode: 'no-cors' allows cross-origin fetches without CORS headers
// - Returns opaque response, which works fine for images/videos/audio
// - Auth token is sent via query parameter since headers are stripped in no-cors mode
// - Server may return redirect to GCS, which will be followed automatically
return fetch(rewrittenUrl, {
method: 'GET',
redirect: 'follow',
mode: 'no-cors'
})
} catch (error) {
console.error('[Auth DEV SW] Request failed:', error)
const originalUrl = new URL(event.request.url)
const rewrittenUrl = new URL(
originalUrl.pathname + originalUrl.search,
baseUrl
)
return fetch(rewrittenUrl, {
mode: 'no-cors',
redirect: 'follow'
})
}
})()
)
})
/**
* 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 DEV 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())
})