mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
1 Commits
pysssss/cu
...
glary/hide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ce6eff55 |
@@ -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": {
|
||||
|
||||
29
apps/website/scripts/refresh-feature-flags-snapshot.ts
Normal file
29
apps/website/scripts/refresh-feature-flags-snapshot.ts
Normal 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`
|
||||
)
|
||||
@@ -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
|
||||
|
||||
6
apps/website/src/data/feature-flags.snapshot.json
Normal file
6
apps/website/src/data/feature-flags.snapshot.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-13T20:30:41.221Z",
|
||||
"flags": {
|
||||
"cloudFreeTier": false
|
||||
}
|
||||
}
|
||||
6
apps/website/src/data/feature-flags.ts
Normal file
6
apps/website/src/data/feature-flags.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FeatureFlagsSnapshot {
|
||||
fetchedAt: string
|
||||
flags: {
|
||||
cloudFreeTier: boolean
|
||||
}
|
||||
}
|
||||
112
apps/website/src/utils/featureFlags.ci.test.ts
Normal file
112
apps/website/src/utils/featureFlags.ci.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
92
apps/website/src/utils/featureFlags.ci.ts
Normal file
92
apps/website/src/utils/featureFlags.ci.ts
Normal 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`
|
||||
}
|
||||
11
apps/website/src/utils/featureFlags.schema.ts
Normal file
11
apps/website/src/utils/featureFlags.schema.ts
Normal 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>
|
||||
190
apps/website/src/utils/featureFlags.test.ts
Normal file
190
apps/website/src/utils/featureFlags.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
189
apps/website/src/utils/featureFlags.ts
Normal file
189
apps/website/src/utils/featureFlags.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user