Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Environment Configuration & Utilities
|
||||
* Central config dictionary and environment discovery
|
||||
*/
|
||||
|
||||
import { getProvider } from './storage.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',
|
||||
BRAND_LOGO: 'BRAND_LOGO',
|
||||
THEME_COLOR: 'THEME_COLOR',
|
||||
UI_SHELL: 'UI_SHELL',
|
||||
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'
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize environment config
|
||||
* @param {Object} appConfig - Configuration from profile
|
||||
*/
|
||||
export function initEnv(appConfig) {
|
||||
const api = appConfig.api || {};
|
||||
config = {
|
||||
APP_NAME: appConfig.id || appConfig.name,
|
||||
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
|
||||
APP_DESCRIPTION: appConfig.description || '',
|
||||
BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
||||
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
||||
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
||||
API_BASE_URL: api.base_url || api.baseURL || '/api',
|
||||
MODULES: appConfig.modules || [],
|
||||
SECURITY_CONFIG: appConfig.security || {},
|
||||
LOCALE: appConfig.locale || null,
|
||||
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
||||
// Store full profile for advanced access
|
||||
_profile: appConfig
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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'
|
||||
} = options;
|
||||
|
||||
const [title, description, themeColor, brandLogo] = await Promise.all([
|
||||
getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback),
|
||||
getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback),
|
||||
getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback),
|
||||
getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg')
|
||||
]);
|
||||
|
||||
document.title = title || titleFallback;
|
||||
setMetaTag('description', description || descriptionFallback);
|
||||
setMetaTag('theme-color', themeColor || themeColorFallback);
|
||||
setFavicon(brandLogo || '/favicon.svg');
|
||||
|
||||
return {
|
||||
title: title || titleFallback,
|
||||
description: description || descriptionFallback,
|
||||
themeColor: themeColor || themeColorFallback,
|
||||
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 async function getConfig(key, altValue = null) {
|
||||
// 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<void>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user