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>
605 lines
18 KiB
JavaScript
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();
|
|
}
|