import AsyncStorage from "@react-native-async-storage/async-storage"; import Constants from "expo-constants"; import { requireOptionalNativeModule } from "expo-modules-core"; import { ExpoConfig } from "expo/config"; import { Linking, Platform } from "react-native"; let LIVE_CONFIG: LiveConfig | null = null; let LIVE_CONFIG_LOADED = false; let LIVE_CONFIG_INITIALIZING = false; let INITIAL_URL_LOADED = false; const LIVE_CONFIG_STORAGE_KEY = "a0_live_config"; export type SnackMode = "production" | "staging"; export const SNACK_ENV: SnackMode = (process.env.EXPO_PUBLIC_SNACK_ENV ?? "production") as SnackMode; if (SNACK_ENV !== "production" && SNACK_ENV !== "staging") { throw new Error( `EXPO_PUBLIC_SNACK_ENV must be "staging" or "production", received "${SNACK_ENV}".` ); } export function selectValueBySnackEnv(values: Record): T { return values[SNACK_ENV]; } export const SNACK_API_URL = selectValueBySnackEnv({ production: "https://exp.host", staging: "https://staging.exp.host", }); export const SNACKAGER_API_URLS = selectValueBySnackEnv({ production: ["https://d37p21p3n8r8ug.cloudfront.net"], staging: [ "https://ductmb1crhe2d.cloudfront.net", "https://d37p21p3n8r8ug.cloudfront.net", ], }); export const SNACKPUB_URL = selectValueBySnackEnv({ production: "https://snackpub.expo.dev", staging: "https://staging-snackpub.expo.dev", }); export interface A0ConfigAddons { [key: string]: { config?: Record; }; } export interface A0Config { projectId: string; apiKey: string; live?: boolean; iconUrl?: string; ek?: string; runtimeVersion?: string; initialDeployment?: { deploymentId: string; deploymentData: string; }; prodConvexUrl?: string; devConvexUrl?: string; addons?: A0ConfigAddons; } const dotenvConfig: A0Config | null = process.env.EXPO_PUBLIC_A0_CONFIG ? JSON.parse(process.env.EXPO_PUBLIC_A0_CONFIG) : null; const ExponentConstants = requireOptionalNativeModule("ExponentConstants"); let embeddedA0Config: A0Config | null = null; let expoConstantsA0Config: A0Config | undefined = Constants.expoConfig?.extra?.a0; // Fall back to ExponentConstants.manifest if we don't have one from Updates let rawAppConfig: ExpoConfig | null = null; if (ExponentConstants && ExponentConstants.manifest) { const appConfig: object | string = ExponentConstants.manifest; // On Android we pass the manifest in JSON form so this step is necessary if (typeof appConfig === "string") { rawAppConfig = JSON.parse(appConfig); } else { rawAppConfig = appConfig as any; } } if (rawAppConfig && rawAppConfig.extra?.a0) { embeddedA0Config = rawAppConfig.extra?.a0 as A0Config; } // Helper function to parse boolean values from strings function parseBool(value: string | undefined | null): boolean { if (!value) return false; const lower = value.toLowerCase(); return lower === 'true' || lower === '1'; } export let raw_A0_CONFIG: A0Config | null = null; if (typeof window !== "undefined" && (window as any).__A0_CONFIG__) { raw_A0_CONFIG = (window as any).__A0_CONFIG__ as A0Config; } else if (process.env.NODE_ENV === "development" && dotenvConfig !== null) { raw_A0_CONFIG = dotenvConfig; } else if (process.env.NODE_ENV === "production" && embeddedA0Config) { // In production, we use the embedded config if it exists (doesn't get updated by expo updates) raw_A0_CONFIG = embeddedA0Config; } else { // Fall back to the config from expo constants raw_A0_CONFIG = expoConstantsA0Config ?? embeddedA0Config; } // Function to apply overrides from individual environment variables function applyEnvVarOverrides(oldConfig: A0Config | null): A0Config | null { let config = oldConfig; /* ------------------------------------------------------------------ */ /* 1) If we still have no config, try to create one from ENV vars */ /* ------------------------------------------------------------------ */ if (!config) { const envProjectId = process.env.EXPO_PUBLIC_A0_PROJECT_ID; const envApiKey = process.env.EXPO_PUBLIC_A0_API_KEY; if (envProjectId && envApiKey) { config = { projectId: envProjectId, apiKey: envApiKey, }; } } /* ------------------------------------------------------------------ */ /* 2) Apply optional ENV-var overrides (iconUrl, live, etc.) */ /* ------------------------------------------------------------------ */ const overrides: Partial = {}; const live = process.env.EXPO_PUBLIC_A0_LIVE; if (live !== undefined) overrides.live = parseBool(live); const iconUrl = process.env.EXPO_PUBLIC_A0_ICON_URL; if (iconUrl) overrides.iconUrl = iconUrl; const ek = process.env.EXPO_PUBLIC_A0_EK; if (ek) overrides.ek = ek; const runtimeVersion = process.env.EXPO_PUBLIC_A0_RUNTIME_VERSION; if (runtimeVersion) overrides.runtimeVersion = runtimeVersion; const initialDeploymentEnv = process.env.EXPO_PUBLIC_A0_INITIAL_DEPLOYMENT; if (initialDeploymentEnv) { try { overrides.initialDeployment = JSON.parse(initialDeploymentEnv); } catch { console.error("Failed to parse EXPO_PUBLIC_A0_INITIAL_DEPLOYMENT"); } } const addonsEnv = process.env.EXPO_PUBLIC_A0_ADDONS; if (addonsEnv) { try { overrides.addons = JSON.parse(addonsEnv); } catch { console.error("Failed to parse EXPO_PUBLIC_A0_ADDONS"); } } // Also ensure required fields are potentially overridden if present in env const envProjectId = process.env.EXPO_PUBLIC_A0_PROJECT_ID; if (envProjectId) overrides.projectId = envProjectId; const envApiKey = process.env.EXPO_PUBLIC_A0_API_KEY; if (envApiKey) overrides.apiKey = envApiKey; // Merge ENV-var overrides if we have a base config; otherwise leave as-is config = config ? { ...config, ...overrides } : null; /* ------------------------------------------------------------------ */ /* 3) Highest-priority: browser URL ?projectId & ?apiKey overrides */ /* ------------------------------------------------------------------ */ // console.log("window", window?.location?.href); if (window?.location?.href) { try { // console.log("Creating URL and getting search params") const params = new URL(window.location.href).searchParams; const paramProjectId = params.get("projectId"); const paramApiKey = params.get("apiKey"); const paramLive = params.get("live"); console.log("projectId", paramProjectId); if (paramProjectId) { config = { ...(config ?? {}), projectId: paramProjectId, apiKey: paramApiKey ?? "", live: paramLive ? parseBool(paramLive) : config?.live, }; } } catch (e) { console.log("Failed to parse URL search params", e); /* ignore URL parsing errors */ } finally { INITIAL_URL_LOADED = true; } } else { // Check if we're on web if (Platform.OS === "web") { INITIAL_URL_LOADED = true; } else { // Only use React Native linking if we're actually not on web (async () => { try { const initialUrl = await Linking.getInitialURL(); if (initialUrl) { const params = new URL(initialUrl).searchParams; const paramProjectId = params.get("a0-project-id"); const paramApiKey = params.get("apiKey") ?? ""; const paramLive = params.get("live") ?? "false"; if (paramProjectId) { console.log("Setting config from initial URL", paramProjectId, paramApiKey, paramLive); A0_CONFIG = { ...(A0_CONFIG), projectId: paramProjectId, apiKey: paramApiKey, live: parseBool(paramLive) }; } } } catch (e) { console.log("Failed to parse URL search params", e); /* ignore URL parsing errors */ } finally { INITIAL_URL_LOADED = true; } })(); } } return config; } export let A0_CONFIG = applyEnvVarOverrides(raw_A0_CONFIG); export const A0_API_URL = process.env.NODE_ENV === "development" ? "http://localhost:3001" : "https://api.a0.dev"; export interface LiveConfig { addons: A0ConfigAddons; devConvexUrl?: string; prodConvexUrl?: string; } export const isLiveConfigLoaded = () => { return LIVE_CONFIG_LOADED; }; /** * Wait until the live config is loaded * @param timeoutMs - Maximum time to wait in milliseconds (default: 10000) * @returns Promise that resolves with the live config when loaded or rejects on timeout */ export const waitForLiveConfig = async (timeoutMs = 10000): Promise => { // If already loaded, return immediately if (LIVE_CONFIG_LOADED) { return LIVE_CONFIG; } initializeLiveConfig(); const startTime = Date.now(); const checkInterval = 50; // Check every 50ms return new Promise((resolve, reject) => { const intervalId = setInterval(() => { if (LIVE_CONFIG_LOADED) { clearInterval(intervalId); resolve(LIVE_CONFIG); return; } // Check for timeout if (Date.now() - startTime > timeoutMs) { clearInterval(intervalId); reject(new Error(`Timeout waiting for live config to load after ${timeoutMs}ms`)); } }, checkInterval); }); }; /** * Wait until the initial URL is loaded and processed * @param timeoutMs - Maximum time to wait in milliseconds (default: 10000) * @returns Promise that resolves when initial URL is loaded or rejects on timeout */ export const waitForInitialUrl = async (timeoutMs = 10000): Promise => { // If already loaded, return immediately if (INITIAL_URL_LOADED) { return; } const startTime = Date.now(); const checkInterval = 50; // Check every 50ms return new Promise((resolve, reject) => { const intervalId = setInterval(() => { if (INITIAL_URL_LOADED) { clearInterval(intervalId); resolve(); return; } // Check for timeout if (Date.now() - startTime > timeoutMs) { clearInterval(intervalId); reject(new Error(`Timeout waiting for initial URL to load after ${timeoutMs}ms`)); } }, checkInterval); }); }; // Load cached config from AsyncStorage export const loadCachedLiveConfig = async (): Promise => { try { const cachedConfig = await AsyncStorage.getItem(LIVE_CONFIG_STORAGE_KEY); if (cachedConfig) { LIVE_CONFIG = JSON.parse(cachedConfig); return true; } } catch (error) { console.error("Failed to load cached live config", error); } return false; }; export const loadLiveConfigFromApi = async () => { try { // Wait for initial URL to be processed before loading live config await waitForInitialUrl(); const projectId = A0_CONFIG?.projectId; const projectKey = A0_CONFIG?.apiKey; const url = `${A0_API_URL}/api/projects/${projectId}/live/app-config`; const response = await fetch(url, { method: "GET", headers: { "a0-project-key": projectKey ?? "", "Content-Type": "application/json", }, }); if (!response.ok) { return; } const data = await response.json(); LIVE_CONFIG = data; // Save to AsyncStorage try { await AsyncStorage.setItem(LIVE_CONFIG_STORAGE_KEY, JSON.stringify(data)); } catch (storageError) { } } catch (error) { } finally { LIVE_CONFIG_LOADED = true; LIVE_CONFIG_INITIALIZING = false; } }; /** * Initialize live config * @returns true if the live config was initialized, false if it was already initialized */ export const initializeLiveConfig = async () => { // Prevent multiple initializations if (LIVE_CONFIG_LOADED || LIVE_CONFIG_INITIALIZING) { return false; } LIVE_CONFIG_INITIALIZING = true; try { const hasCachedConfig = await loadCachedLiveConfig(); // Fetch fresh config in background regardless of whether we had cached config loadLiveConfigFromApi(); } catch (error) { console.error("Failed to initialize live config", error); LIVE_CONFIG_INITIALIZING = false; } return true; }; /** * Check if the app has an addon enabled * @param addonKey - The key of the addon to check * @returns true if the addon is present, false otherwise */ export const doesAppHaveAddon = (addonKey: string) => { const addons = LIVE_CONFIG === null ? A0_CONFIG?.addons : LIVE_CONFIG?.addons; return addons?.[addonKey] !== undefined; }; export const getLiveConfig = (): LiveConfig | null => { return LIVE_CONFIG; }; export const getConvexUrl = async (force: boolean = false) => { if (typeof window !== "undefined" && A0_CONFIG?.live) { const convexUrl = A0_CONFIG?.prodConvexUrl ?? A0_CONFIG?.devConvexUrl; if (convexUrl || !force) { return convexUrl; } } if (force) { // When force is true, repeatedly fetch until we get a convex URL with a 30-second timeout const timeoutMs = 30000; // 30 seconds const intervalMs = 2000; // Check every 2 seconds const startTime = Date.now(); return new Promise((resolve, reject) => { // Set up the timeout const timeoutId = setTimeout(() => { reject(new Error( "Unable to load Convex URL. It's likely that something went wrong while deploying your Convex Backend. Please check the console for errors or Update your Convex Folder.", )); }, timeoutMs); const checkForConvexUrl = async () => { try { await waitForLiveConfig(); const convexUrl = A0_CONFIG?.live ? LIVE_CONFIG?.prodConvexUrl : LIVE_CONFIG?.devConvexUrl; if (convexUrl) { clearTimeout(timeoutId); resolve(convexUrl); return; } // Check if we still have time left if (Date.now() - startTime < timeoutMs - intervalMs) { // Fetch fresh config and schedule next check await loadLiveConfigFromApi(); setTimeout(checkForConvexUrl, intervalMs); } } catch (error) { clearTimeout(timeoutId); reject(error); } }; // Start the first check checkForConvexUrl(); }); } await waitForLiveConfig(); return A0_CONFIG?.live ? LIVE_CONFIG?.prodConvexUrl : LIVE_CONFIG?.devConvexUrl; };