Skip to content

Commit c67c93f

Browse files
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 <[email protected]>
1 parent e0e6d15 commit c67c93f

File tree

22 files changed

+1013
-22
lines changed

22 files changed

+1013
-22
lines changed

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export default defineConfig([
6060
'**/vite.config.*.timestamp*',
6161
'**/vitest.config.*.timestamp*',
6262
'packages/registry-types/src/comfyRegistryTypes.ts',
63+
'public/auth-dev-sw.js',
6364
'public/auth-sw.js',
6465
'src/extensions/core/*',
6566
'src/scripts/*',

knip.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ const config: KnipConfig = {
4242
'packages/registry-types/src/comfyRegistryTypes.ts',
4343
// Used by a custom node (that should move off of this)
4444
'src/scripts/ui/components/splitButton.ts',
45-
// Service worker - registered at runtime via navigator.serviceWorker.register()
46-
'public/auth-sw.js'
45+
// Service workers - registered at runtime via navigator.serviceWorker.register()
46+
'public/auth-sw.js',
47+
'public/auth-dev-sw.js'
4748
],
4849
compilers: {
4950
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

public/auth-dev-sw.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* @fileoverview Authentication Service Worker (Development Version)
3+
* Intercepts /api/view requests and rewrites them to a configurable base URL with auth token.
4+
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
5+
* This version is used in development to proxy requests to staging/test environments.
6+
* Default base URL: https://testcloud.comfy.org (configurable via SET_BASE_URL message)
7+
*/
8+
9+
/**
10+
* @typedef {Object} AuthHeader
11+
* @property {string} Authorization - Bearer token for authentication
12+
*/
13+
14+
/**
15+
* @typedef {Object} CachedAuth
16+
* @property {AuthHeader|null} header
17+
* @property {number} expiresAt - Timestamp when cache expires
18+
*/
19+
20+
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
21+
22+
/** @type {CachedAuth|null} */
23+
let authCache = null
24+
25+
/** @type {Promise<AuthHeader|null>|null} */
26+
let authRequestInFlight = null
27+
28+
/** @type {string} */
29+
let baseUrl = 'https://testcloud.comfy.org'
30+
31+
self.addEventListener('message', (event) => {
32+
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
33+
authCache = null
34+
authRequestInFlight = null
35+
}
36+
37+
if (event.data.type === 'SET_BASE_URL') {
38+
baseUrl = event.data.baseUrl
39+
console.log('[Auth DEV SW] Base URL set to:', baseUrl)
40+
}
41+
})
42+
43+
self.addEventListener('fetch', (event) => {
44+
const url = new URL(event.request.url)
45+
46+
if (
47+
!url.pathname.startsWith('/api/view') &&
48+
!url.pathname.startsWith('/api/viewvideo')
49+
) {
50+
return
51+
}
52+
53+
event.respondWith(
54+
(async () => {
55+
try {
56+
// Rewrite URL to use configured base URL (default: stagingcloud.comfy.org)
57+
const originalUrl = new URL(event.request.url)
58+
const rewrittenUrl = new URL(
59+
originalUrl.pathname + originalUrl.search,
60+
baseUrl
61+
)
62+
63+
const authHeader = await getAuthHeader()
64+
65+
// With mode: 'no-cors', Authorization headers are stripped by the browser
66+
// So we add the token to the URL as a query parameter instead
67+
if (authHeader && authHeader.Authorization) {
68+
const token = authHeader.Authorization.replace('Bearer ', '')
69+
rewrittenUrl.searchParams.set('token', token)
70+
}
71+
72+
// Cross-origin request requires no-cors mode
73+
// - mode: 'no-cors' allows cross-origin fetches without CORS headers
74+
// - Returns opaque response, which works fine for images/videos/audio
75+
// - Auth token is sent via query parameter since headers are stripped in no-cors mode
76+
// - Server may return redirect to GCS, which will be followed automatically
77+
return fetch(rewrittenUrl, {
78+
method: 'GET',
79+
redirect: 'follow',
80+
mode: 'no-cors'
81+
})
82+
} catch (error) {
83+
console.error('[Auth DEV SW] Request failed:', error)
84+
const originalUrl = new URL(event.request.url)
85+
const rewrittenUrl = new URL(
86+
originalUrl.pathname + originalUrl.search,
87+
baseUrl
88+
)
89+
return fetch(rewrittenUrl, {
90+
mode: 'no-cors',
91+
redirect: 'follow'
92+
})
93+
}
94+
})()
95+
)
96+
})
97+
98+
/**
99+
* Gets auth header from cache or requests from main thread
100+
* @returns {Promise<AuthHeader|null>}
101+
*/
102+
async function getAuthHeader() {
103+
// Return cached value if valid
104+
if (authCache && authCache.expiresAt > Date.now()) {
105+
return authCache.header
106+
}
107+
108+
// Clear expired cache
109+
if (authCache) {
110+
authCache = null
111+
}
112+
113+
// Deduplicate concurrent requests
114+
if (authRequestInFlight) {
115+
return authRequestInFlight
116+
}
117+
118+
authRequestInFlight = requestAuthHeaderFromMainThread()
119+
const header = await authRequestInFlight
120+
authRequestInFlight = null
121+
122+
// Cache the result
123+
if (header) {
124+
authCache = {
125+
header,
126+
expiresAt: Date.now() + CACHE_TTL_MS
127+
}
128+
}
129+
130+
return header
131+
}
132+
133+
/**
134+
* Requests auth header from main thread via MessageChannel
135+
* @returns {Promise<AuthHeader|null>}
136+
*/
137+
async function requestAuthHeaderFromMainThread() {
138+
const clients = await self.clients.matchAll()
139+
if (clients.length === 0) {
140+
return null
141+
}
142+
143+
const messageChannel = new MessageChannel()
144+
145+
return new Promise((resolve) => {
146+
let timeoutId
147+
148+
messageChannel.port1.onmessage = (event) => {
149+
clearTimeout(timeoutId)
150+
resolve(event.data.authHeader)
151+
}
152+
153+
timeoutId = setTimeout(() => {
154+
console.error(
155+
'[Auth DEV SW] Timeout waiting for auth header from main thread'
156+
)
157+
resolve(null)
158+
}, 1000)
159+
160+
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
161+
messageChannel.port2
162+
])
163+
})
164+
}
165+
166+
self.addEventListener('activate', (event) => {
167+
event.waitUntil(self.clients.claim())
168+
})

src/App.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ const showContextMenu = (event: MouseEvent) => {
4242
}
4343
4444
onMounted(() => {
45-
// @ts-expect-error fixme ts strict error
4645
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
4746
4847
if (isElectron()) {

src/platform/auth/serviceWorker/register.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,34 @@ async function registerAuthServiceWorker(): Promise<void> {
1313
}
1414

1515
try {
16-
await navigator.serviceWorker.register('/auth-sw.js')
16+
// Use dev service worker in development mode (rewrites to configured backend URL with token in query param)
17+
// Use production service worker in production (same-origin requests with Authorization header)
18+
const swPath = import.meta.env.DEV ? '/auth-dev-sw.js' : '/auth-sw.js'
19+
const registration = await navigator.serviceWorker.register(swPath)
20+
21+
// Configure base URL for dev service worker
22+
if (import.meta.env.DEV) {
23+
console.warn('[Auth DEV SW] Registering development serviceworker')
24+
// Use the same URL that Vite proxy is using
25+
const baseUrl = __DEV_SERVER_COMFYUI_URL__
26+
navigator.serviceWorker.controller?.postMessage({
27+
type: 'SET_BASE_URL',
28+
baseUrl
29+
})
30+
31+
// Also set base URL when service worker becomes active
32+
registration.addEventListener('updatefound', () => {
33+
const newWorker = registration.installing
34+
newWorker?.addEventListener('statechange', () => {
35+
if (newWorker.state === 'activated') {
36+
navigator.serviceWorker.controller?.postMessage({
37+
type: 'SET_BASE_URL',
38+
baseUrl
39+
})
40+
}
41+
})
42+
})
43+
}
1744

1845
setupAuthHeaderProvider()
1946
setupCacheInvalidation()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @fileoverview Adapter to convert V2 history format to V1 format
3+
* @module platform/remote/comfyui/history/adapters/v2ToV1Adapter
4+
*
5+
* Converts cloud API V2 response format to the V1 format expected by the app.
6+
*/
7+
8+
import type { HistoryTaskItem, TaskPrompt } from '../types/historyV1Types'
9+
import type {
10+
HistoryResponseV2,
11+
RawHistoryItemV2,
12+
TaskOutput,
13+
TaskPromptV2
14+
} from '../types/historyV2Types'
15+
16+
/**
17+
* Maps V2 prompt format to V1 prompt tuple format.
18+
*/
19+
function mapPromptV2toV1(
20+
promptV2: TaskPromptV2,
21+
outputs: TaskOutput
22+
): TaskPrompt {
23+
const outputNodesIds = Object.keys(outputs)
24+
const { priority, prompt_id, extra_data } = promptV2
25+
return [priority, prompt_id, {}, extra_data, outputNodesIds]
26+
}
27+
28+
/**
29+
* Maps V2 history format to V1 history format.
30+
*/
31+
export function mapHistoryV2toHistory(
32+
historyV2Response: HistoryResponseV2
33+
): HistoryTaskItem[] {
34+
return historyV2Response.history.map(
35+
({ prompt, status, outputs, meta }: RawHistoryItemV2): HistoryTaskItem => ({
36+
taskType: 'History' as const,
37+
prompt: mapPromptV2toV1(prompt, outputs),
38+
status,
39+
outputs,
40+
meta
41+
})
42+
)
43+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @fileoverview V1 History Fetcher - Desktop/localhost API
3+
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV1
4+
*
5+
* Fetches history directly from V1 API endpoint.
6+
* Used by desktop and localhost distributions.
7+
*/
8+
9+
import type {
10+
HistoryTaskItem,
11+
HistoryV1Response
12+
} from '../types/historyV1Types'
13+
14+
/**
15+
* Fetches history from V1 API endpoint
16+
* @param api - API instance with fetchApi method
17+
* @param maxItems - Maximum number of history items to fetch
18+
* @returns Promise resolving to V1 history response
19+
*/
20+
export async function fetchHistoryV1(
21+
fetchApi: (url: string) => Promise<Response>,
22+
maxItems: number = 200
23+
): Promise<HistoryV1Response> {
24+
const res = await fetchApi(`/history?max_items=${maxItems}`)
25+
const json: Record<
26+
string,
27+
Omit<HistoryTaskItem, 'taskType'>
28+
> = await res.json()
29+
30+
return {
31+
History: Object.values(json).map((item) => ({
32+
...item,
33+
taskType: 'History'
34+
}))
35+
}
36+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @fileoverview V2 History Fetcher - Cloud API with adapter
3+
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV2
4+
*
5+
* Fetches history from V2 API endpoint and converts to V1 format.
6+
* Used exclusively by cloud distribution.
7+
*/
8+
9+
import { mapHistoryV2toHistory } from '../adapters/v2ToV1Adapter'
10+
import type { HistoryV1Response } from '../types/historyV1Types'
11+
import type { HistoryResponseV2 } from '../types/historyV2Types'
12+
13+
/**
14+
* Fetches history from V2 API endpoint and adapts to V1 format
15+
* @param fetchApi - API instance with fetchApi method
16+
* @param maxItems - Maximum number of history items to fetch
17+
* @returns Promise resolving to V1 history response (adapted from V2)
18+
*/
19+
export async function fetchHistoryV2(
20+
fetchApi: (url: string) => Promise<Response>,
21+
maxItems: number = 200
22+
): Promise<HistoryV1Response> {
23+
const res = await fetchApi(`/history_v2?max_items=${maxItems}`)
24+
const rawData: HistoryResponseV2 = await res.json()
25+
const adaptedHistory = mapHistoryV2toHistory(rawData)
26+
return { History: adaptedHistory }
27+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @fileoverview History API module - Distribution-aware exports
3+
* @module platform/remote/comfyui/history
4+
*
5+
* This module provides a unified history fetching interface that automatically
6+
* uses the correct implementation based on build-time distribution constant.
7+
*
8+
* - Cloud builds: Uses V2 API with adapter (tree-shakes V1 fetcher)
9+
* - Desktop/localhost builds: Uses V1 API directly (tree-shakes V2 fetcher + adapter)
10+
*
11+
* The rest of the application only needs to import from this module and use
12+
* V1 types - all distribution-specific details are encapsulated here.
13+
*/
14+
15+
import { isCloud } from '@/platform/distribution/types'
16+
import { fetchHistoryV1 } from './fetchers/fetchHistoryV1'
17+
import { fetchHistoryV2 } from './fetchers/fetchHistoryV2'
18+
19+
/**
20+
* Fetches history using the appropriate API for the current distribution.
21+
* Build-time constant enables dead code elimination - only one implementation
22+
* will be included in the final bundle.
23+
*/
24+
export const fetchHistory = isCloud ? fetchHistoryV2 : fetchHistoryV1
25+
26+
/**
27+
* Export only V1 types publicly - consumers don't need to know about V2
28+
*/
29+
export type * from './types'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @fileoverview History V1 types - Public interface used throughout the app
3+
* @module platform/remote/comfyui/history/types/historyV1Types
4+
*
5+
* These types represent the V1 history format that the application expects.
6+
* Both desktop (direct V1 API) and cloud (V2 API + adapter) return data in this format.
7+
*/
8+
9+
import type { HistoryTaskItem, TaskPrompt } from '@/schemas/apiSchema'
10+
11+
export interface HistoryV1Response {
12+
History: HistoryTaskItem[]
13+
}
14+
15+
export type { HistoryTaskItem, TaskPrompt }

0 commit comments

Comments
 (0)