From f5402f2b43a17a54d022adc91eaf810b97cc7968 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 01:23:32 +0100 Subject: [PATCH] feat: add runtime feature overrides --- CHANGELOG.md | 3 + scripts/copy-dist-assets.js | 5 +- src/commands/query-ids.ts | 39 +++++- src/lib/features.json | 5 + src/lib/runtime-features.ts | 184 +++++++++++++++++++++++++++++ src/lib/twitter-client-features.ts | 36 +++--- tests/runtime-features.test.ts | 113 ++++++++++++++++++ 7 files changed, 366 insertions(+), 19 deletions(-) create mode 100644 src/lib/features.json create mode 100644 src/lib/runtime-features.ts create mode 100644 tests/runtime-features.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa055c4..672addb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed - Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold - Bookmarks can resume pagination from a cursor (#26) — thanks @leonho + +### Changed +- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`). ## 0.5.1 — 2026-01-01 ### Changed diff --git a/scripts/copy-dist-assets.js b/scripts/copy-dist-assets.js index 4be6d61..8e71c78 100644 --- a/scripts/copy-dist-assets.js +++ b/scripts/copy-dist-assets.js @@ -7,10 +7,13 @@ const files = [ from: resolve(root, 'src/lib/query-ids.json'), to: resolve(root, 'dist/lib/query-ids.json'), }, + { + from: resolve(root, 'src/lib/features.json'), + to: resolve(root, 'dist/lib/features.json'), + }, ]; for (const file of files) { mkdirSync(dirname(file.to), { recursive: true }); writeFileSync(file.to, readFileSync(file.from)); } - diff --git a/src/commands/query-ids.ts b/src/commands/query-ids.ts index ad38eb6..6181bf1 100644 --- a/src/commands/query-ids.ts +++ b/src/commands/query-ids.ts @@ -1,7 +1,25 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; +import { + type FeatureOverrides, + getFeatureOverridesSnapshot, + refreshFeatureOverridesCache, +} from '../lib/runtime-features.js'; import { runtimeQueryIds } from '../lib/runtime-query-ids.js'; +function countFeatureOverrides(overrides: FeatureOverrides): number { + let count = 0; + if (overrides.global) { + count += Object.keys(overrides.global).length; + } + if (overrides.sets) { + for (const setOverrides of Object.values(overrides.sets)) { + count += Object.keys(setOverrides).length; + } + } + return count; +} + export function registerQueryIdsCommand(program: Command, ctx: CliContext): void { program .command('query-ids') @@ -25,16 +43,31 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void if (cmdOpts.fresh) { console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`); await runtimeQueryIds.refresh(operations, { force: true }); + console.error(`${ctx.p('info')}Refreshing feature overrides…`); + await refreshFeatureOverridesCache(); } + const featureSnapshot = getFeatureOverridesSnapshot(); const info = await runtimeQueryIds.getSnapshotInfo(); if (!info) { if (cmdOpts.json) { - console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2)); + console.log( + JSON.stringify( + { + cached: false, + cachePath: runtimeQueryIds.cachePath, + featuresPath: featureSnapshot.cachePath, + features: featureSnapshot.overrides, + }, + null, + 2, + ), + ); return; } console.log(`${ctx.p('warn')}No cached query IDs yet.`); console.log(`${ctx.p('info')}Run: bird query-ids --fresh`); + console.log(`features_path: ${featureSnapshot.cachePath}`); return; } @@ -49,6 +82,8 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void ageMs: info.ageMs, ids: info.snapshot.ids, discovery: info.snapshot.discovery, + featuresPath: featureSnapshot.cachePath, + features: featureSnapshot.overrides, }, null, 2, @@ -62,5 +97,7 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void console.log(`fetched_at: ${info.snapshot.fetchedAt}`); console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`); console.log(`ops: ${Object.keys(info.snapshot.ids).length}`); + console.log(`features_path: ${featureSnapshot.cachePath}`); + console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`); }); } diff --git a/src/lib/features.json b/src/lib/features.json new file mode 100644 index 0000000..d43088b --- /dev/null +++ b/src/lib/features.json @@ -0,0 +1,5 @@ +{ + "global": { + "responsive_web_grok_annotations_enabled": false + } +} diff --git a/src/lib/runtime-features.ts b/src/lib/runtime-features.ts new file mode 100644 index 0000000..a4cfc69 --- /dev/null +++ b/src/lib/runtime-features.ts @@ -0,0 +1,184 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. +import defaultOverrides from './features.json' with { type: 'json' }; + +export type FeatureOverrides = { + global?: Record; + sets?: Record>; +}; + +type NormalizedFeatureOverrides = { + global: Record; + sets: Record>; +}; + +export type FeatureOverridesSnapshot = { + cachePath: string; + overrides: FeatureOverrides; +}; + +const DEFAULT_CACHE_FILENAME = 'features.json'; +let cachedOverrides: NormalizedFeatureOverrides | null = null; + +function normalizeFeatureMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry === 'boolean') { + result[key] = entry; + } + } + return result; +} + +function normalizeOverrides(value: unknown): NormalizedFeatureOverrides { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { global: {}, sets: {} }; + } + const record = value as Record; + const global = normalizeFeatureMap(record.global); + const sets: Record> = {}; + const rawSets = + record.sets && typeof record.sets === 'object' && !Array.isArray(record.sets) + ? (record.sets as Record) + : {}; + for (const [setName, setValue] of Object.entries(rawSets)) { + const normalized = normalizeFeatureMap(setValue); + if (Object.keys(normalized).length > 0) { + sets[setName] = normalized; + } + } + return { global, sets }; +} + +function mergeOverrides( + base: NormalizedFeatureOverrides, + next: NormalizedFeatureOverrides, +): NormalizedFeatureOverrides { + const sets: Record> = { ...base.sets }; + for (const [setName, overrides] of Object.entries(next.sets)) { + const existing = sets[setName]; + sets[setName] = existing ? { ...existing, ...overrides } : { ...overrides }; + } + return { + global: { ...base.global, ...next.global }, + sets, + }; +} + +function toFeatureOverrides(overrides: NormalizedFeatureOverrides): FeatureOverrides { + const result: FeatureOverrides = {}; + if (Object.keys(overrides.global).length > 0) { + result.global = overrides.global; + } + const setEntries = Object.entries(overrides.sets).filter(([, value]) => Object.keys(value).length > 0); + if (setEntries.length > 0) { + result.sets = Object.fromEntries(setEntries); + } + return result; +} + +function resolveFeaturesCachePath(): string { + const override = process.env.BIRD_FEATURES_CACHE ?? process.env.BIRD_FEATURES_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override.trim()); + } + return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME); +} + +function readOverridesFromFile(cachePath: string): NormalizedFeatureOverrides | null { + if (!existsSync(cachePath)) { + return null; + } + try { + const raw = readFileSync(cachePath, 'utf8'); + return normalizeOverrides(JSON.parse(raw)); + } catch { + return null; + } +} + +function readOverridesFromEnv(): NormalizedFeatureOverrides | null { + const raw = process.env.BIRD_FEATURES_JSON; + if (!raw || raw.trim().length === 0) { + return null; + } + try { + return normalizeOverrides(JSON.parse(raw)); + } catch { + return null; + } +} + +function writeOverridesToDisk(cachePath: string, overrides: NormalizedFeatureOverrides): Promise { + const payload = toFeatureOverrides(overrides); + return mkdir(path.dirname(cachePath), { recursive: true }).then(() => + writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'), + ); +} + +export function loadFeatureOverrides(): NormalizedFeatureOverrides { + if (cachedOverrides) { + return cachedOverrides; + } + const base = normalizeOverrides(defaultOverrides); + const fromFile = readOverridesFromFile(resolveFeaturesCachePath()); + const fromEnv = readOverridesFromEnv(); + let merged = base; + if (fromFile) { + merged = mergeOverrides(merged, fromFile); + } + if (fromEnv) { + merged = mergeOverrides(merged, fromEnv); + } + cachedOverrides = merged; + return merged; +} + +export function getFeatureOverridesSnapshot(): FeatureOverridesSnapshot { + const overrides = toFeatureOverrides(loadFeatureOverrides()); + return { + cachePath: resolveFeaturesCachePath(), + overrides, + }; +} + +export function applyFeatureOverrides(setName: string, base: Record): Record { + const overrides = loadFeatureOverrides(); + const globalOverrides = overrides.global; + const setOverrides = overrides.sets[setName]; + if (Object.keys(globalOverrides).length === 0 && (!setOverrides || Object.keys(setOverrides).length === 0)) { + return base; + } + if (setOverrides) { + return { + ...base, + ...globalOverrides, + ...setOverrides, + }; + } + + return { + ...base, + ...globalOverrides, + }; +} + +export async function refreshFeatureOverridesCache(): Promise { + const cachePath = resolveFeaturesCachePath(); + const base = normalizeOverrides(defaultOverrides); + const fromFile = readOverridesFromFile(cachePath); + const merged = mergeOverrides(base, fromFile ?? { global: {}, sets: {} }); + await writeOverridesToDisk(cachePath, merged); + cachedOverrides = null; + return { cachePath, overrides: toFeatureOverrides(merged) }; +} + +export function clearFeatureOverridesCache(): void { + cachedOverrides = null; +} diff --git a/src/lib/twitter-client-features.ts b/src/lib/twitter-client-features.ts index b8aeae6..18581e9 100644 --- a/src/lib/twitter-client-features.ts +++ b/src/lib/twitter-client-features.ts @@ -1,5 +1,7 @@ +import { applyFeatureOverrides } from './runtime-features.js'; + export function buildArticleFeatures(): Record { - return { + return applyFeatureOverrides('article', { rweb_video_screen_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: true, responsive_web_profile_redirect_enabled: true, @@ -35,11 +37,11 @@ export function buildArticleFeatures(): Record { responsive_web_grok_imagine_annotation_enabled: true, responsive_web_grok_community_note_auto_translation_is_enabled: false, responsive_web_enhance_cards_enabled: false, - }; + }); } export function buildTweetDetailFeatures(): Record { - return { + return applyFeatureOverrides('tweetDetail', { ...buildArticleFeatures(), responsive_web_graphql_exclude_directive_enabled: true, communities_web_enable_tweet_community_results_fetch: true, @@ -52,7 +54,7 @@ export function buildTweetDetailFeatures(): Record { tweet_awards_web_tipping_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false, verified_phone_label_enabled: false, - }; + }); } export function buildArticleFieldToggles(): Record { @@ -67,7 +69,7 @@ export function buildArticleFieldToggles(): Record { } export function buildSearchFeatures(): Record { - return { + return applyFeatureOverrides('search', { rweb_video_screen_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: true, responsive_web_profile_redirect_enabled: true, @@ -105,11 +107,11 @@ export function buildSearchFeatures(): Record { responsive_web_grok_community_note_auto_translation_is_enabled: false, articles_preview_enabled: true, responsive_web_enhance_cards_enabled: false, - }; + }); } export function buildTweetCreateFeatures(): Record { - return { + return applyFeatureOverrides('tweetCreate', { rweb_video_screen_enabled: true, creator_subscriptions_tweet_preview_api_enabled: true, premium_content_api_read_enabled: false, @@ -145,11 +147,11 @@ export function buildTweetCreateFeatures(): Record { responsive_web_grok_imagine_annotation_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_enhance_cards_enabled: false, - }; + }); } export function buildTimelineFeatures(): Record { - return { + return applyFeatureOverrides('timeline', { ...buildSearchFeatures(), blue_business_profile_image_shape_enabled: true, responsive_web_text_conversations_enabled: false, @@ -159,22 +161,22 @@ export function buildTimelineFeatures(): Record { interactive_text_enabled: true, longform_notetweets_richtext_consumption_enabled: true, responsive_web_media_download_video_enabled: false, - }; + }); } export function buildBookmarksFeatures(): Record { - return { + return applyFeatureOverrides('bookmarks', { ...buildTimelineFeatures(), graphql_timeline_v2_bookmark_timeline: true, - }; + }); } export function buildLikesFeatures(): Record { - return buildTimelineFeatures(); + return applyFeatureOverrides('likes', buildTimelineFeatures()); } export function buildListsFeatures(): Record { - return { + return applyFeatureOverrides('lists', { rweb_video_screen_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: true, responsive_web_profile_redirect_enabled: true, @@ -210,11 +212,11 @@ export function buildListsFeatures(): Record { responsive_web_grok_imagine_annotation_enabled: true, responsive_web_grok_community_note_auto_translation_is_enabled: false, responsive_web_enhance_cards_enabled: false, - }; + }); } export function buildFollowingFeatures(): Record { - return { + return applyFeatureOverrides('following', { rweb_video_screen_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: false, responsive_web_profile_redirect_enabled: true, @@ -250,5 +252,5 @@ export function buildFollowingFeatures(): Record { responsive_web_grok_imagine_annotation_enabled: false, responsive_web_grok_community_note_auto_translation_is_enabled: false, responsive_web_enhance_cards_enabled: false, - }; + }); } diff --git a/tests/runtime-features.test.ts b/tests/runtime-features.test.ts new file mode 100644 index 0000000..0333bda --- /dev/null +++ b/tests/runtime-features.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { clearFeatureOverridesCache, refreshFeatureOverridesCache } from '../src/lib/runtime-features.js'; +import { buildLikesFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js'; + +describe('runtime-features', () => { + afterEach(() => { + delete process.env.BIRD_FEATURES_JSON; + delete process.env.BIRD_FEATURES_CACHE; + delete process.env.BIRD_FEATURES_PATH; + clearFeatureOverridesCache(); + }); + + it('applies global and set overrides from env json', () => { + process.env.BIRD_FEATURES_JSON = JSON.stringify({ + global: { + global_flag: true, + ignored: 'nope', + }, + sets: { + search: { + global_flag: false, + search_only: true, + bad: 1, + }, + likes: { + likes_only: true, + }, + }, + }); + + clearFeatureOverridesCache(); + const search = buildSearchFeatures(); + expect(search.global_flag).toBe(false); + expect(search.search_only).toBe(true); + expect(search.bad).toBeUndefined(); + + const likes = buildLikesFeatures(); + expect(likes.global_flag).toBe(true); + expect(likes.likes_only).toBe(true); + }); + + it('refresh merges defaults with existing cache', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'features.json'); + + await writeFile( + cachePath, + JSON.stringify( + { + global: { + existing_flag: true, + }, + sets: { + search: { + existing_search: false, + }, + }, + }, + null, + 2, + ), + ); + + process.env.BIRD_FEATURES_PATH = cachePath; + await refreshFeatureOverridesCache(); + + const raw = await readFile(cachePath, 'utf8'); + const parsed = JSON.parse(raw) as { + global?: Record; + sets?: Record>; + }; + + expect(parsed.global?.existing_flag).toBe(true); + expect(parsed.global?.responsive_web_grok_annotations_enabled).toBe(false); + expect(parsed.sets?.search?.existing_search).toBe(false); + }); + + it('applies overrides from cache file', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'features.json'); + + await writeFile( + cachePath, + JSON.stringify( + { + global: { + file_flag: true, + }, + sets: { + search: { + file_search: true, + }, + }, + }, + null, + 2, + ), + ); + + process.env.BIRD_FEATURES_PATH = cachePath; + clearFeatureOverridesCache(); + + const search = buildSearchFeatures(); + expect(search.file_flag).toBe(true); + expect(search.file_search).toBe(true); + }); +});