Files
bface/src/platform/env.js
Amer Agovic 859db6ccb2 Release 1.0.8 with platform, security, and UI hardening.
Adds API filter registry, style theme registry, SW bitmask cache clear, KV namespacing, session expiry checks, accessibility improvements, and expanded test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 21:08:21 -05:00

605 lines
18 KiB
JavaScript

/**
* Environment Configuration & Utilities
* Central config dictionary and environment discovery
*/
import { configureKeyValueStoreBackend, getProvider } from './storage.js';
import { api as apiClient } from './api.js';
// Private storage instance for config
const storage = getProvider('kv', 'config');
// Config dictionary - populated by loader
let config = {};
let consoleLoggerInstalled = false;
const lockedKeys = new Set();
const LOGGER_ICONS = {
debug: '.',
info: 'i',
log: '>',
warn: '!',
error: 'x'
};
const NATIVE_CONSOLE = {
log: console.log.bind(console),
info: (console.info || console.log).bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: (console.debug || console.log).bind(console)
};
const perfNow = () => {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
};
function normalizeLogLevel(level = 'log') {
if (level === 'info') return 'info';
if (level === 'warn') return 'warn';
if (level === 'error') return 'error';
if (level === 'debug') return 'debug';
return 'log';
}
function splitSourceAndMessage(args = []) {
if (args.length === 0) {
return {
source: 'App',
messageParts: []
};
}
const [firstArg, ...restArgs] = args;
if (typeof firstArg !== 'string') {
return {
source: 'App',
messageParts: args
};
}
const match = firstArg.match(/^\[([^\]]+)\]\s*(.*)$/s);
if (!match) {
return {
source: 'App',
messageParts: args
};
}
const [, source, firstMessage] = match;
const messageParts = firstMessage ? [firstMessage, ...restArgs] : restArgs;
return {
source,
messageParts
};
}
// Special config keys
export const CONFIG_KEYS = {
APP_NAME: 'APP_NAME',
APP_DISPLAY_NAME: 'APP_DISPLAY_NAME',
APP_DESCRIPTION: 'APP_DESCRIPTION',
FAVICON: 'FAVICON',
BRAND_LOGO: 'BRAND_LOGO',
THEME_COLOR: 'THEME_COLOR',
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
UI_SHELL: 'UI_SHELL',
STYLE_THEME: 'STYLE_THEME',
INITIAL_ROUTE: 'INITIAL_ROUTE',
STORAGE_BACKEND: 'STORAGE_BACKEND',
API_BASE_URL: 'API_BASE_URL',
MODULES: 'MODULES',
SECURITY_CONFIG: 'SECURITY_CONFIG',
LOCALE: 'LOCALE',
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
DEV_HOST: 'DEV_HOST',
/** Whether the app should register and use a service worker. */
SERVICE_WORKER_ENABLED: 'SERVICE_WORKER_ENABLED'
};
// do not allow edits on these keys via setConfig - they are meant to be set from profile or env vars and not overridden at runtime
[
CONFIG_KEYS.API_BASE_URL,
CONFIG_KEYS.MODULES,
CONFIG_KEYS.SECURITY_CONFIG
].forEach((key) => lockedKeys.add(key));
/**
* Resolve dev-host flag from profile (explicit) or bundler hints.
* Profile wins: `dev_host: true|false` or `runtime.dev: true|false`.
* Otherwise Vite `import.meta.env.DEV`, else false.
* @param {Object|null|undefined} appConfig
* @returns {boolean}
*/
function resolveDevHostFlag(appConfig) {
if (appConfig && typeof appConfig.dev_host === 'boolean') {
return appConfig.dev_host;
}
if (appConfig?.runtime && typeof appConfig.runtime.dev === 'boolean') {
return appConfig.runtime.dev;
}
if (typeof import.meta !== 'undefined' && import.meta.env) {
return Boolean(import.meta.env.DEV);
}
return false;
}
function resolveDesktopApiBaseURL() {
if (typeof window === 'undefined') {
return null;
}
const bridgeBase = window.__bfaceDesktop?.apiBaseUrl;
if (typeof bridgeBase === 'string' && bridgeBase.trim()) {
return bridgeBase.trim();
}
return null;
}
/**
* Resolve service-worker enablement from profile.
* Defaults to true for backward compatibility with PWA templates.
* @param {Object|null|undefined} appConfig
* @returns {boolean}
*/
export function resolveServiceWorkerEnabled(appConfig = {}) {
if (typeof appConfig?.service_worker?.enabled === 'boolean') {
return appConfig.service_worker.enabled;
}
if (typeof appConfig?.serviceWorker?.enabled === 'boolean') {
return appConfig.serviceWorker.enabled;
}
if (typeof appConfig?.pwa?.service_worker?.enabled === 'boolean') {
return appConfig.pwa.service_worker.enabled;
}
return true;
}
/**
* Initialize environment config
* @param {Object} appConfig - Configuration from profile
*/
export function initEnv(appConfig) {
const api = appConfig.api || {};
const resolvedApiBaseURL =
resolveDesktopApiBaseURL()
|| appConfig.backend?.base_url
|| api.base_url
|| api.baseURL
|| '/api';
config = {
APP_NAME: appConfig.id || appConfig.name,
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
APP_DESCRIPTION: appConfig.description || '',
FAVICON: appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
STYLE_THEME:
appConfig.style_theme
|| appConfig.styleTheme
|| appConfig.ui?.style_theme
|| appConfig.ui?.styleTheme
|| null,
INITIAL_ROUTE: appConfig.initial_route || appConfig.initialRoute || appConfig.ui?.initial_route || appConfig.ui?.initialRoute || '/home',
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
API_BASE_URL: resolvedApiBaseURL,
MODULES: appConfig.modules || [],
SECURITY_CONFIG: appConfig.security || {},
LOCALE: appConfig.locale || null,
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
[CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig),
// Store full profile for advanced access
_profile: appConfig
};
apiClient.setBaseURL(resolvedApiBaseURL);
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
}
function resolveSystemLocale() {
if (typeof navigator !== 'undefined' && typeof navigator.language === 'string' && navigator.language.trim()) {
return navigator.language.trim();
}
return 'en-US';
}
/**
* Bootstrap environment as early as possible from the selected profile.
* This gives the app a single source of truth before full boot runs.
* @param {Object} appConfig
* @param {Object} options
* @param {boolean} [options.installLoggerFirst=true]
*/
export function bootstrapEnv(appConfig, options = {}) {
const { installLoggerFirst = true } = options;
if (installLoggerFirst) {
installConsoleLogger();
}
if (appConfig && typeof appConfig === 'object') {
initEnv(appConfig);
}
return getConfigDict();
}
export function log(source = 'App', level = 'log', ...messageParts) {
const normalizedLevel = normalizeLogLevel(level);
const sink = NATIVE_CONSOLE[normalizedLevel] || NATIVE_CONSOLE.log;
const timestamp = new Date().toISOString();
const icon = LOGGER_ICONS[normalizedLevel] || LOGGER_ICONS.log;
sink(`${timestamp} [${source}] ${icon}`, ...messageParts);
}
export function createLogger(source = 'App') {
return {
debug: (...messageParts) => log(source, 'debug', ...messageParts),
info: (...messageParts) => log(source, 'info', ...messageParts),
log: (...messageParts) => log(source, 'log', ...messageParts),
warn: (...messageParts) => log(source, 'warn', ...messageParts),
error: (...messageParts) => log(source, 'error', ...messageParts)
};
}
export function startTrace(source = 'App', label = 'operation', metadata = null) {
const startedAt = perfNow();
if (metadata !== null && metadata !== undefined) {
log(source, 'debug', `${label} started`, metadata);
} else {
log(source, 'debug', `${label} started`);
}
return {
end(extra = null, level = 'log') {
const durationMs = Math.round((perfNow() - startedAt) * 100) / 100;
if (extra !== null && extra !== undefined) {
log(source, level, `${label} finished in ${durationMs}ms`, extra);
} else {
log(source, level, `${label} finished in ${durationMs}ms`);
}
return durationMs;
},
fail(error = null) {
const durationMs = Math.round((perfNow() - startedAt) * 100) / 100;
log(source, 'error', `${label} failed in ${durationMs}ms`, error);
return durationMs;
}
};
}
export async function traceAsync(source, label, fn, metadata = null) {
const trace = startTrace(source, label, metadata);
try {
const result = await fn();
trace.end();
return result;
} catch (error) {
trace.fail(error);
throw error;
}
}
export function installConsoleLogger() {
if (consoleLoggerInstalled || typeof console === 'undefined') {
return;
}
consoleLoggerInstalled = true;
console.log = (...args) => {
const { source, messageParts } = splitSourceAndMessage(args);
log(source, 'log', ...messageParts);
};
console.info = (...args) => {
const { source, messageParts } = splitSourceAndMessage(args);
log(source, 'info', ...messageParts);
};
console.warn = (...args) => {
const { source, messageParts } = splitSourceAndMessage(args);
log(source, 'warn', ...messageParts);
};
console.error = (...args) => {
const { source, messageParts } = splitSourceAndMessage(args);
log(source, 'error', ...messageParts);
};
console.debug = (...args) => {
const { source, messageParts } = splitSourceAndMessage(args);
log(source, 'debug', ...messageParts);
};
}
export function lockConfigKey(key) {
if (!key) {
return false;
}
lockedKeys.add(key);
return true;
}
export function unlockConfigKey(key) {
if (!key) {
return false;
}
return lockedKeys.delete(key);
}
export function isConfigKeyLocked(key) {
return lockedKeys.has(key);
}
export function getLockedConfigKeys() {
return Array.from(lockedKeys);
}
function setMetaTag(name, content) {
if (typeof document === 'undefined') {
return;
}
let element = document.querySelector(`meta[name="${name}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute('name', name);
document.head.appendChild(element);
}
element.setAttribute('content', content);
}
export function setDocumentBackground(color) {
if (typeof document === 'undefined' || !color) {
return;
}
document.documentElement.style.backgroundColor = color;
if (document.body) {
document.body.style.backgroundColor = color;
}
}
function setFavicon(href) {
if (typeof document === 'undefined' || !href) {
return;
}
let element = document.querySelector('link[rel="icon"]');
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'icon');
document.head.appendChild(element);
}
element.setAttribute('href', href);
if (!element.getAttribute('type')) {
element.setAttribute('type', 'image/svg+xml');
}
}
export async function syncDocumentHeadFromConfig(options = {}) {
if (typeof document === 'undefined') {
return null;
}
const {
titleFallback = 'PWA Template',
descriptionFallback = '',
themeColorFallback = '#000000',
backgroundColorFallback = '#ffffff'
} = options;
const [favicon, title, description, themeColor, backgroundColor, brandLogo] = await Promise.all([
getConfig(CONFIG_KEYS.FAVICON, '/favicon.svg'),
getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback),
getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback),
getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback),
getConfig(CONFIG_KEYS.BACKGROUND_COLOR, backgroundColorFallback),
getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg')
]);
document.title = title || titleFallback;
setMetaTag('description', description || descriptionFallback);
setMetaTag('theme-color', themeColor || themeColorFallback);
setDocumentBackground(backgroundColor || backgroundColorFallback);
setFavicon(favicon || '/favicon.svg');
return {
title: title || titleFallback,
description: description || descriptionFallback,
themeColor: themeColor || themeColorFallback,
backgroundColor: backgroundColor || backgroundColorFallback,
favicon: favicon || '/favicon.svg',
brandLogo: brandLogo || '/favicon.svg'
};
}
/**
* Get config value by key
* Checks in order: storage (if key exists) → config dictionary → environment variables → altValue
* Uses storage.hasKey() as a guard to skip unnecessary storage.get() calls for profile config keys
* @param {string} key - Config key
* @param {any} altValue - Alternative value if not found
* @returns {Promise<any>} Config value or altValue
*/
export function getConfigSync(key, altValue = null) {
if (config.hasOwnProperty(key)) {
return config[key];
}
if (typeof import.meta !== 'undefined' && import.meta.env) {
const envKey = `VITE_${key}`;
const envValue = import.meta.env[envKey];
if (envValue !== undefined) {
return envValue;
}
}
return altValue;
}
export async function getConfig(key, altValue = null) {
// Locked profile keys cannot be overridden by persisted storage values.
if (isConfigKeyLocked(key) && config.hasOwnProperty(key)) {
return config[key];
}
// 1. Check if key exists in storage first (quick check to avoid unnecessary get)
try {
const exists = await storage.hasKey(key);
if (exists) {
// Only do full storage.get() if key exists
const storedValue = await storage.get(key, null);
if (storedValue !== null && storedValue !== undefined) {
return storedValue;
}
}
// If key doesn't exist in storage, skip to config dictionary (faster path)
} catch (error) {
// Storage might not be available, continue to next check
console.warn(`[Env] Failed to check storage for "${key}":`, error);
}
// 2. Check config dictionary (in-memory config from profile)
if (config.hasOwnProperty(key)) {
return config[key];
}
// 3. Fall back to environment variables (Vite env vars)
// Check if import.meta.env exists (available in Vite, not in Node)
if (typeof import.meta !== 'undefined' && import.meta.env) {
const envKey = `VITE_${key}`;
const envValue = import.meta.env[envKey];
if (envValue !== undefined) {
return envValue;
}
}
// 4. Return alternative value
return altValue;
}
/**
* Set config value
* If key exists in config dictionary, update it there (in-memory).
* Otherwise, save to storage (for persistence).
* @param {string} key - Config key
* @param {any} value - Value to set
* @returns {Promise<boolean>}
*/
export async function setConfig(key, value) {
if (isConfigKeyLocked(key)) {
console.warn(`[Env] Refusing to set locked config key "${key}". Unlock it explicitly first if this override is intentional.`);
return false;
}
// If key exists in config dictionary, update it there (in-memory config)
if (config.hasOwnProperty(key)) {
config[key] = value;
return true;
} else {
// Otherwise, save to storage (for state/settings persistence)
try {
await storage.set(key, value);
return true;
} catch (error) {
console.warn(`[Env] Failed to set "${key}" in storage:`, error);
// Fallback: store in config dictionary if storage fails
config[key] = value;
return true;
}
}
}
/**
* Get full config object (read-only copy)
* @returns {Object} Config dictionary
*/
export function getConfigDict() {
return { ...config };
}
export async function getLocale(altValue = null) {
const savedLocale = await getConfig(CONFIG_KEYS.LOCALE, null);
if (savedLocale && typeof savedLocale === 'string') {
config[CONFIG_KEYS.LOCALE] = savedLocale;
return savedLocale;
}
return altValue || resolveSystemLocale();
}
export function getLocaleSync(altValue = null) {
return config[CONFIG_KEYS.LOCALE] || altValue || resolveSystemLocale();
}
export async function setLocale(locale) {
const normalizedLocale = typeof locale === 'string' && locale.trim() ? locale.trim() : null;
config[CONFIG_KEYS.LOCALE] = normalizedLocale;
try {
if (normalizedLocale) {
await storage.set(CONFIG_KEYS.LOCALE, normalizedLocale);
} else {
await storage.remove(CONFIG_KEYS.LOCALE);
}
} catch (error) {
console.warn('[Env] Failed to persist locale:', error);
}
return normalizedLocale || resolveSystemLocale();
}
/**
* Whether the app is treated as a development host (dev tooling, SW policy, etc.).
* Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}.
* For persisted overrides, use {@link getConfig} with {@link CONFIG_KEYS.DEV_HOST} in async code.
*/
export function isDevelopment() {
if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.DEV_HOST)) {
return Boolean(config[CONFIG_KEYS.DEV_HOST]);
}
if (typeof import.meta !== 'undefined' && import.meta.env) {
return Boolean(import.meta.env.DEV);
}
return false;
}
/**
* Whether service worker registration is enabled for this app.
* Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}.
*/
export function isServiceWorkerEnabledSync(altValue = true) {
if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.SERVICE_WORKER_ENABLED)) {
return Boolean(config[CONFIG_KEYS.SERVICE_WORKER_ENABLED]);
}
return altValue;
}
/**
* Async profile-aware service worker enablement check.
*/
export async function isServiceWorkerEnabled(altValue = true) {
const configured = await getConfig(CONFIG_KEYS.SERVICE_WORKER_ENABLED, null);
if (typeof configured === 'boolean') {
return configured;
}
return isServiceWorkerEnabledSync(altValue);
}
/**
* Inverse of {@link isDevelopment} when no explicit production flag exists.
*/
export function isProduction() {
if (typeof import.meta !== 'undefined' && import.meta.env && typeof import.meta.env.PROD === 'boolean') {
return import.meta.env.PROD;
}
return !isDevelopment();
}