Files
simstudioai--sim/apps/sim/lib/core/config/appconfig.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

168 lines
5.7 KiB
TypeScript

import {
AppConfigDataClient,
GetLatestConfigurationCommand,
type GetLatestConfigurationCommandOutput,
StartConfigurationSessionCommand,
} from '@aws-sdk/client-appconfigdata'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
import { env } from '@/lib/core/config/env'
const logger = createLogger('AppConfig')
const DEFAULT_TTL_MS = 30_000
export interface AppConfigProfileIdentifiers {
application: string
environment: string
profile: string
}
interface CacheEntry<T> {
/** Last successfully parsed value, or `null` if the config is empty/unseeded. */
value: T | null
/** True once any poll has completed (success, empty payload, or error). */
loaded: boolean
/** Token for the next `GetLatestConfiguration` poll, rotated on each call. */
nextToken: string | undefined
expiresAt: number
/** In-flight poll, shared so concurrent callers don't each hit AppConfig. */
inflight: Promise<T | null> | null
}
const cache = new Map<string, CacheEntry<unknown>>()
let client: AppConfigDataClient | null = null
/**
* Lazily construct the AppConfig data-plane client. Never instantiated unless a
* caller actually fetches a profile, so deployments without AppConfig configured
* never reach for AWS credentials.
*/
function getClient(): AppConfigDataClient {
if (!client) {
client = new AppConfigDataClient({
region: env.AWS_REGION,
credentials: getAwsCredentialsFromEnv(),
})
}
return client
}
function cacheKey(ids: AppConfigProfileIdentifiers): string {
return `${ids.application}/${ids.environment}/${ids.profile}`
}
/**
* Run one AppConfig poll for `entry`: starts a session if no token is held, then
* calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an
* unseeded profile) and the previous value is kept. Any error is logged and the
* last good value is retained. Marks the entry `loaded` on any outcome so callers
* never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we
* don't poll faster than the server allows (which would throttle).
*/
async function poll<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T,
entry: CacheEntry<T>
): Promise<T | null> {
let response: GetLatestConfigurationCommandOutput
try {
const dataClient = getClient()
if (!entry.nextToken) {
const session = await dataClient.send(
new StartConfigurationSessionCommand({
ApplicationIdentifier: ids.application,
EnvironmentIdentifier: ids.environment,
ConfigurationProfileIdentifier: ids.profile,
})
)
entry.nextToken = session.InitialConfigurationToken
}
response = await dataClient.send(
new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken })
)
entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken
} catch (error) {
// Network/session failure: drop the token so the next attempt starts a fresh
// session (handles expired or invalid tokens). Mark loaded + back off so we
// serve the fallback and retry in the background rather than blocking every
// request during an outage.
entry.nextToken = undefined
entry.expiresAt = Date.now() + DEFAULT_TTL_MS
entry.loaded = true
logger.error('AppConfig fetch failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
return entry.value
}
// Parse outside the network try: a decode/parse error must NOT discard the
// already-rotated session token — the round trip succeeded, so the next poll
// can reuse it instead of opening a new session. Keep the last good value.
try {
if (response.Configuration && response.Configuration.length > 0) {
const text = new TextDecoder().decode(response.Configuration)
entry.value = parse(JSON.parse(text))
}
} catch (error) {
logger.error('AppConfig response parse failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
}
const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000
entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs)
entry.loaded = true
return entry.value
}
/**
* Fetch and cache a single AppConfig configuration profile as JSON.
*
* Profile-agnostic: pass the `application`/`environment` (from env) and a
* `profile` constant owned by the calling feature. Uses an in-process TTL cache
* with stale-while-revalidate — a warm cache returns immediately and refreshes
* in the background once the TTL lapses, so no request blocks on the AppConfig
* round trip after the first (cold) fetch. Concurrent callers share one in-flight
* poll (avoids racing the rotating session token). Returns `null` when the config
* is empty/unseeded or the first fetch fails.
*/
export async function fetchAppConfigProfile<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T
): Promise<T | null> {
const key = cacheKey(ids)
const entry = (cache.get(key) as CacheEntry<T> | undefined) ?? {
value: null,
loaded: false,
nextToken: undefined,
expiresAt: 0,
inflight: null,
}
cache.set(key, entry)
// Cold: never polled — await a single shared poll so concurrent callers don't
// each hit AppConfig (and don't race the rotating session token).
if (!entry.loaded) {
entry.inflight ??= poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
return entry.inflight
}
// Warm but stale: serve cached value, refresh once in the background.
if (Date.now() >= entry.expiresAt && !entry.inflight) {
entry.inflight = poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
}
return entry.value
}