Compare commits

...

1 Commits

Author SHA1 Message Date
Glary-Bot
d2ce6eff55 feat(website): snapshot SHOW_FREE_TIER from /features at build time
Follow-on to #12165 which introduced apps/website/src/config/features.ts with a hard-coded SHOW_FREE_TIER=false. Wire that constant to a build-time snapshot of the public /features endpoint so toggling new_free_tier_subscriptions in cloud/PostHog propagates to comfy.org on the next build without a code change.

features.ts now imports the snapshot synchronously and re-exports SHOW_FREE_TIER as snapshot.flags.cloudFreeTier, so existing consumers (PriceSection.vue, cloud PricingSection.vue, pricing.spec.ts) need no changes.

New files mirror the Ashby ISR-like pattern already in apps/website: src/utils/featureFlags.ts (fetcher with inflight cache, retry, timeout, zod validation, snapshot fallback), src/utils/featureFlags.schema.ts (passthrough so unknown server fields are ignored), src/utils/featureFlags.ci.ts (GitHub Actions annotations and step summary), src/data/feature-flags.ts (snapshot type), src/data/feature-flags.snapshot.json (bundled fallback), scripts/refresh-feature-flags-snapshot.ts. Tests cover fetcher and CI reporter.

Snapshot defaults to cloudFreeTier=false. The backend does not yet return new_free_tier_subscriptions, so the live fetch resolves to the same default; once the flag is added there, the next refresh-snapshot run flips SHOW_FREE_TIER.

New script: pnpm --filter @comfyorg/website feature-flags:refresh-snapshot.
2026-05-13 20:31:04 +00:00
10 changed files with 639 additions and 1 deletions

View File

@@ -17,6 +17,7 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"feature-flags:refresh-snapshot": "tsx ./scripts/refresh-feature-flags-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {

View File

@@ -0,0 +1,29 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchFeatureFlagsForBuild } from '../src/utils/featureFlags'
const snapshotPath = fileURLToPath(
new URL('../src/data/feature-flags.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchFeatureFlagsForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
process.stdout.write(
`Wrote feature flags snapshot to ${snapshotPath}: cloudFreeTier=${outcome.snapshot.flags.cloudFreeTier}\n`
)

View File

@@ -1 +1,3 @@
export const SHOW_FREE_TIER = false
import snapshot from '../data/feature-flags.snapshot.json' with { type: 'json' }
export const SHOW_FREE_TIER = snapshot.flags.cloudFreeTier

View File

@@ -0,0 +1,6 @@
{
"fetchedAt": "2026-05-13T20:30:41.221Z",
"flags": {
"cloudFreeTier": false
}
}

View File

@@ -0,0 +1,6 @@
export interface FeatureFlagsSnapshot {
fetchedAt: string
flags: {
cloudFreeTier: boolean
}
}

View File

@@ -0,0 +1,112 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './featureFlags'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import {
reportFeatureFlagsOutcome,
resetFeatureFlagsReporterForTests
} from './featureFlags.ci'
function baseSnapshot(cloudFreeTier = false): FeatureFlagsSnapshot {
return {
fetchedAt: new Date().toISOString(),
flags: { cloudFreeTier }
}
}
function freshOutcome(cloudFreeTier = false): FetchOutcome {
return { status: 'fresh', snapshot: baseSnapshot(cloudFreeTier) }
}
describe('reportFeatureFlagsOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetFeatureFlagsReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'feature-flags-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a fresh outcome', () => {
reportFeatureFlagsOutcome(freshOutcome())
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('records cloudFreeTier value in the step summary', () => {
reportFeatureFlagsOutcome(freshOutcome(true))
expect(readFileSync(summaryPath, 'utf8')).toContain(
'| **cloudFreeTier** | true |'
)
})
it('emits exactly one annotation across repeated calls', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 500 Server Error',
snapshot: baseSnapshot()
})
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 500 Server Error',
snapshot: baseSnapshot()
})
expect(writeSpy).toHaveBeenCalledTimes(1)
})
it('emits ::error for schema-mismatch stale outcomes', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason:
'schema validation failed: new_free_tier_subscriptions: Expected boolean',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Feature flags schema mismatch')
})
it('emits ::warning for transient API unavailability', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain(
'::warning title=Feature flags API unavailable'
)
})
it('emits ::error for a failed outcome', () => {
reportFeatureFlagsOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Feature flags fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportFeatureFlagsOutcome(freshOutcome())).not.toThrow()
})
})

View File

@@ -0,0 +1,92 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './featureFlags'
let hasReported = false
export function resetFeatureFlagsReporterForTests(): void {
hasReported = false
}
export function reportFeatureFlagsOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(
`feature-flags reporter: failed to write GITHUB_STEP_SUMMARY: ${message}\n`
)
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') return []
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Feature flags fetch failed and no snapshot is available::Cannot build site without feature flags.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website feature-flags:refresh-snapshot\` locally.%0A 2. Commit apps/website/src/data/feature-flags.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('schema')) {
return `::error title=Feature flags schema mismatch::${escaped}. The /features API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Inspect the response at https://api.comfy.org/features.%0A 2. Update apps/website/src/utils/featureFlags.schema.ts to match the new shape.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Feature flags authentication failed::${escaped}. The /features endpoint should be public; check the backend. Build continues with the last-known-good snapshot.`
}
return `::warning title=Feature flags API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check the status of https://api.comfy.org/features.%0A 2. Re-run this workflow once the API is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 🚩 Feature Flags (/features)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from /features)'])
rows.push(['cloudFreeTier', String(outcome.snapshot.flags.cloudFreeTier)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — /features fetch failed)'])
rows.push(['cloudFreeTier', String(outcome.snapshot.flags.cloudFreeTier)])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod'
export const FeaturesResponseSchema = z
.object({
new_free_tier_subscriptions: z.boolean().optional(),
free_tier_credits: z.number().optional(),
partner_node_conversion_rate: z.number().optional()
})
.passthrough()
export type FeaturesResponse = z.infer<typeof FeaturesResponseSchema>

View File

@@ -0,0 +1,190 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import {
fetchFeatureFlagsForBuild,
resetFeatureFlagsFetcherForTests
} from './featureFlags'
const BASE_URL = 'https://api.test'
const tempSnapshotDirs: string[] = []
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(cloudFreeTier: boolean): FeatureFlagsSnapshot {
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
flags: { cloudFreeTier }
}
}
function withSnapshotDir(snapshot: FeatureFlagsSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'feature-flags-test-'))
tempSnapshotDirs.push(dir)
const file = join(dir, 'feature-flags.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchFeatureFlagsForBuild', () => {
beforeEach(() => {
resetFeatureFlagsFetcherForTests()
})
afterEach(() => {
for (const dir of tempSnapshotDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
it('returns fresh with cloudFreeTier=true when /features sets the flag', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: true })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(true)
expect(fetchImpl).toHaveBeenCalledWith(
`${BASE_URL}/features`,
expect.objectContaining({ method: 'GET' })
)
})
it('defaults cloudFreeTier to false when the flag is absent from /features', async () => {
const fetchImpl = vi.fn(async () =>
response({ partner_node_conversion_rate: 0.05 })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('returns fresh with cloudFreeTier=false when explicitly disabled', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: false })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('returns stale with snapshot when the API returns 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(true))
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(outcome.snapshot.flags.cloudFreeTier).toBe(true)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(false))
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
})
it('falls back to snapshot on schema validation failure', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(false))
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: 'yes' })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^schema validation/)
})
it('falls back to the bundled snapshot when fetch fails and the override is missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const fetchImpl = vi.fn(async () => response({}, { status: 500 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: true })
)
const opts = {
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchFeatureFlagsForBuild(opts),
fetchFeatureFlagsForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot(true)
const snapshotUrl = withSnapshotDir(snapshot)
const fs = await import('node:fs')
const initial = fs.readFileSync(snapshotUrl).toString()
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: false })
)
await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(snapshotUrl).toString()
expect(after).toBe(initial)
})
})

View File

@@ -0,0 +1,189 @@
import { readFile } from 'node:fs/promises'
import type { FeaturesResponse } from './featureFlags.schema'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import { FeaturesResponseSchema } from './featureFlags.schema'
import bundledSnapshot from '../data/feature-flags.snapshot.json' with { type: 'json' }
const DEFAULT_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export type FetchOutcome =
| { status: 'fresh'; snapshot: FeatureFlagsSnapshot }
| { status: 'stale'; snapshot: FeatureFlagsSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchFeatureFlagsOptions {
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetFeatureFlagsFetcherForTests(): void {
inflight = undefined
}
export function fetchFeatureFlagsForBuild(
options: FetchFeatureFlagsOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchFeatureFlagsForBuild(options)
return inflight
}
async function doFetchFeatureFlagsForBuild(
options: FetchFeatureFlagsOptions
): Promise<FetchOutcome> {
const result = await tryFetchAndParse(options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
flags: deriveFlags(result.features)
}
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
features: FeaturesResponse
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
options: FetchFeatureFlagsOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl.replace(/\/+$/, '')}/features`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const parsed = FeaturesResponseSchema.safeParse(response.body)
if (!parsed.success) {
return {
kind: 'err',
reason: `schema validation failed: ${parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return { kind: 'ok', features: parsed.data }
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function deriveFlags(
features: FeaturesResponse
): FeatureFlagsSnapshot['flags'] {
return {
cloudFreeTier: features.new_free_tier_subscriptions ?? false
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<FeatureFlagsSnapshot | null> {
if (snapshotUrl) {
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isFeatureFlagsSnapshot(parsed)) return parsed
} catch {
// Fall through to the bundled snapshot if the override is unreadable.
}
}
return isFeatureFlagsSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
function isFeatureFlagsSnapshot(value: unknown): value is FeatureFlagsSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; flags?: unknown }
if (typeof candidate.fetchedAt !== 'string') return false
if (candidate.flags === null || typeof candidate.flags !== 'object') {
return false
}
const flags = candidate.flags as { cloudFreeTier?: unknown }
return typeof flags.cloudFreeTier === 'boolean'
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}