Release 1.0.10 with subpath deployment and modal menu invokes.

Add app_base/router_base config, compat path helpers, and scoped service worker
registration so apps can mount under a URL prefix. Wire invoke_type modal through
a handler registry and open the notification center from the standard menu flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Amer Agovic
2026-06-16 16:44:32 -05:00
parent 5810008fa5
commit aa872bdd6b
9 changed files with 381 additions and 33 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@reliancy/bface",
"version": "1.0.8",
"version": "1.0.10",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
+135 -13
View File
@@ -7,7 +7,7 @@
* added when needed.
*/
import { CONFIG_KEYS, getConfig, setConfig } from './env.js';
import { CONFIG_KEYS, getConfig, getConfigSync, setConfig, normalizeRouterBase } from './env.js';
import { getDesktopBridgeSafe, getHostKind, isElectronHost } from './host.js';
// Config key for last visited route path
@@ -640,6 +640,124 @@ export async function getHostInfo() {
// URL/Path Management (Browser URL synchronization)
// ============================================================================
export { normalizeRouterBase, resolveProfileBases } from './env.js';
/**
* Vite/public asset base path with trailing slash (e.g. `/admin/`).
* @param {string} routerBase
* @returns {string}
*/
export function getViteBasePath(routerBase = '/') {
const normalized = normalizeRouterBase(routerBase);
if (normalized === '/') {
return '/';
}
return `${normalized}/`;
}
/**
* Resolve router base from env config, falling back to the bundler base URL.
* @returns {string}
*/
export function getRouterBaseFromMeta() {
if (typeof document !== 'undefined') {
const baseElement = document.querySelector('base[href]');
if (baseElement) {
try {
const href = baseElement.getAttribute('href') || '/';
const resolved = new URL(href, window.location.origin);
return normalizeRouterBase(resolved.pathname || '/');
} catch {
// Fall through to other hints.
}
}
}
if (typeof globalThis !== 'undefined' && globalThis.__BFACE_ROUTER_BASE__) {
return normalizeRouterBase(globalThis.__BFACE_ROUTER_BASE__);
}
return '/';
}
function resolveActiveRouterBase() {
const fromConfig = getConfigSync(CONFIG_KEYS.ROUTER_BASE, null);
if (fromConfig != null) {
return normalizeRouterBase(fromConfig);
}
if (typeof globalThis !== 'undefined' && globalThis.__BFACE_ROUTER_BASE__) {
return normalizeRouterBase(globalThis.__BFACE_ROUTER_BASE__);
}
return getRouterBaseFromMeta();
}
/**
* Strip the public router base from a browser pathname.
* @param {string} pathname
* @param {string} [routerBase]
* @returns {string}
*/
export function stripRouterBase(pathname = '/', routerBase = resolveActiveRouterBase()) {
const path = String(pathname || '/');
const base = normalizeRouterBase(routerBase);
if (!base || base === '/') {
return path || '/';
}
if (path === base) {
return '/';
}
if (path.startsWith(`${base}/`)) {
return path.slice(base.length) || '/';
}
// Ignore paths outside the app mount (stale /home redirects, foreign host routes).
return '/';
}
/**
* Prefix an internal route path with the public router base.
* @param {string} routePath
* @param {string} [routerBase]
* @returns {string}
*/
export function withRouterBase(routePath = '/', routerBase = resolveActiveRouterBase()) {
const path = routePath.startsWith('/') ? routePath : `/${routePath}`;
const base = normalizeRouterBase(routerBase);
if (!base || base === '/') {
return path;
}
if (path === '/') {
return `${base}/`;
}
return `${base}${path}`;
}
/**
* Prefix a root-relative public asset path with the app base.
* Leaves `/api` and already-prefixed paths unchanged.
* @param {string} path
* @param {string} [appBase]
* @returns {string}
*/
export function prefixPublicAssetPath(path = '', appBase = '/') {
const normalized = String(path || '').trim();
if (!normalized || !normalized.startsWith('/')) {
return normalized;
}
if (normalized.startsWith('/api')) {
return normalized;
}
const base = normalizeRouterBase(appBase);
if (base === '/') {
return normalized;
}
if (normalized === base || normalized.startsWith(`${base}/`)) {
return normalized;
}
return `${base}${normalized}`;
}
/**
* Get router path (current path the router should use)
* Checks browser URL first, then localStorage for last visited path, then falls back to default
@@ -648,12 +766,13 @@ export async function getHostInfo() {
* @returns {Promise<string>} Router path to use
*/
export async function getRouterPath(defaultPath = '/') {
const routerBase = resolveActiveRouterBase();
// First, check browser URL (web only)
if (typeof window !== 'undefined' && window.location) {
const urlPath = window.location.pathname;
// If URL has a meaningful path (not just '/'), use it
if (urlPath && urlPath !== '/') {
return urlPath;
const internalPath = stripRouterBase(window.location.pathname, routerBase);
if (internalPath && internalPath !== '/') {
return internalPath;
}
}
@@ -688,19 +807,21 @@ export async function getRouterPath(defaultPath = '/') {
* Web: Uses history.pushState or history.replaceState
* React Native: Only saves to storage (no URL to update)
*
* @param {string} path - Path to navigate to (e.g., '/dashboard')
* @param {string} path - Internal router path to navigate to (e.g., '/dashboard')
* @param {boolean} [replace=false] - Whether to replace current history entry
*/
export async function setRouterPath(path, replace = false, options = {}) {
const { notify = true, state = null } = options;
const fullPath = path.startsWith('/') ? path : '/' + path;
const routerBase = resolveActiveRouterBase();
const internalPath = path.startsWith('/') ? path : `/${path}`;
const publicPath = withRouterBase(internalPath, routerBase);
// Update browser URL (web only)
if (typeof window !== 'undefined' && window.history) {
if (replace) {
window.history.replaceState(state, '', fullPath);
window.history.replaceState(state, '', publicPath);
} else {
window.history.pushState(state, '', fullPath);
window.history.pushState(state, '', publicPath);
}
if (notify) {
@@ -712,14 +833,14 @@ export async function setRouterPath(path, replace = false, options = {}) {
}
}
// Save to config for persistence (works on all platforms)
// Save internal path for persistence (works on all platforms)
try {
const scopedLastPathKey = await getScopedLastPathConfigKey();
await setConfig(scopedLastPathKey, fullPath);
await setConfig(scopedLastPathKey, internalPath);
const appName = await getConfig(CONFIG_KEYS.APP_NAME, DEFAULT_APP_NAME);
if (appName === DEFAULT_APP_NAME) {
await setConfig(LAST_PATH_CONFIG_KEY, fullPath);
await setConfig(LAST_PATH_CONFIG_KEY, internalPath);
}
} catch (error) {
console.warn('[Compat] Failed to save path to config:', error);
@@ -741,7 +862,8 @@ export function subscribeToPathChanges(listener) {
}
const handlePopState = (event) => {
const path = window.location.pathname;
const routerBase = resolveActiveRouterBase();
const path = stripRouterBase(window.location.pathname, routerBase);
try {
listener(path, event.state);
} catch (error) {
+85 -4
View File
@@ -96,14 +96,20 @@ export const CONFIG_KEYS = {
/** 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'
SERVICE_WORKER_ENABLED: 'SERVICE_WORKER_ENABLED',
/** Public deployment prefix (Vite base, static assets). No trailing slash except `/`. */
APP_BASE: 'APP_BASE',
/** URL prefix owned by the in-app router. Defaults to APP_BASE. */
ROUTER_BASE: 'ROUTER_BASE'
};
// 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
CONFIG_KEYS.SECURITY_CONFIG,
CONFIG_KEYS.APP_BASE,
CONFIG_KEYS.ROUTER_BASE
].forEach((key) => lockedKeys.add(key));
/**
@@ -158,6 +164,72 @@ export function resolveServiceWorkerEnabled(appConfig = {}) {
return true;
}
/**
* Normalize a router/app base path to a leading slash without a trailing slash.
* @param {string} value
* @returns {string}
*/
export function normalizeRouterBase(value = '/') {
const raw = String(value ?? '/').trim() || '/';
if (raw === '/') {
return '/';
}
const withLeading = raw.startsWith('/') ? raw : `/${raw}`;
return withLeading.replace(/\/+$/, '');
}
/**
* Resolve app/router base paths from a profile object.
* @param {Object} appConfig
* @returns {{ app_base: string, router_base: string }}
*/
export function resolveProfileBases(appConfig = {}) {
const rawAppBase =
appConfig.app_base
?? appConfig.appBase
?? appConfig.ui?.app_base
?? appConfig.ui?.appBase
?? null;
const rawRouterBase =
appConfig.router_base
?? appConfig.routerBase
?? appConfig.ui?.router_base
?? appConfig.ui?.routerBase
?? null;
if (rawAppBase == null && rawRouterBase == null) {
return { app_base: '/', router_base: '/' };
}
if (rawAppBase != null && rawRouterBase != null) {
return {
app_base: normalizeRouterBase(rawAppBase),
router_base: normalizeRouterBase(rawRouterBase)
};
}
const single = normalizeRouterBase(rawAppBase ?? rawRouterBase);
return { app_base: single, router_base: single };
}
function prefixPublicAssetPath(path = '', appBase = '/') {
const normalized = String(path || '').trim();
if (!normalized || !normalized.startsWith('/')) {
return normalized;
}
if (normalized.startsWith('/api')) {
return normalized;
}
const base = normalizeRouterBase(appBase);
if (base === '/') {
return normalized;
}
if (normalized === base || normalized.startsWith(`${base}/`)) {
return normalized;
}
return `${base}${normalized}`;
}
/**
* Initialize environment config
* @param {Object} appConfig - Configuration from profile
@@ -170,12 +242,15 @@ export function initEnv(appConfig) {
|| api.base_url
|| api.baseURL
|| '/api';
const { app_base, router_base } = resolveProfileBases(appConfig);
const faviconPath = appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg';
const brandLogoPath = appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg';
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',
FAVICON: prefixPublicAssetPath(faviconPath, app_base),
BRAND_LOGO: prefixPublicAssetPath(brandLogoPath, app_base),
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
@@ -193,9 +268,15 @@ export function initEnv(appConfig) {
LOCALE: appConfig.locale || null,
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
[CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig),
[CONFIG_KEYS.APP_BASE]: app_base,
[CONFIG_KEYS.ROUTER_BASE]: router_base,
// Store full profile for advanced access
_profile: appConfig
};
if (typeof globalThis !== 'undefined') {
globalThis.__BFACE_APP_BASE__ = app_base;
globalThis.__BFACE_ROUTER_BASE__ = router_base;
}
apiClient.setBaseURL(resolvedApiBaseURL);
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
}
+46 -1
View File
@@ -72,10 +72,55 @@ export const InvokeHandlers = {
* @param {Event|null} event - Original event object
*/
goToModal: (menuItem, eventSource = null, event = null) => {
// To be implemented in App.jsx
return invokeRegisteredModal(menuItem, eventSource, event);
}
};
const modalHandlers = new Map();
/**
* Register a handler for invoke_type "modal" targets (e.g. notifications panel).
* @param {string} target - invoke_target value from the menu item
* @param {Function} handler - (menuItem, eventSource, event) => any
* @returns {Function} Unregister function
*/
export function registerModalHandler(target, handler) {
const key = String(target || '').trim();
if (!key || typeof handler !== 'function') {
return () => {};
}
modalHandlers.set(key, handler);
return () => {
modalHandlers.delete(key);
};
}
/**
* Resolve a registered modal handler by invoke_target.
* @param {string} target
* @returns {Function|null}
*/
export function resolveModalHandler(target) {
const key = String(target || '').trim();
return key ? modalHandlers.get(key) || null : null;
}
export function invokeRegisteredModal(menuItem, eventSource = null, event = null) {
if (!menuItem?.invoke_target) {
console.warn('[MenuItem] invoke_type "modal" requires invoke_target to be set');
return null;
}
const handler = resolveModalHandler(menuItem.invoke_target);
if (!handler) {
console.warn(`[MenuItem] No modal handler registered for "${menuItem.invoke_target}"`);
return null;
}
return handler(menuItem, eventSource, event);
}
/**
* Subscribe to menu changes
* @param {Function} listener - Callback function called when menu changes
+39 -6
View File
@@ -7,14 +7,14 @@
import { isElectronHost, isTauriHost } from './host.js';
import {
getConfig,
getConfigSync,
isDevelopment,
isServiceWorkerEnabled,
isServiceWorkerEnabledSync,
normalizeRouterBase,
CONFIG_KEYS
} from './env.js';
const SW_PATH = '/sw.js';
const SW_SCOPE = '/';
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
/** Bit flags for {@link clearPWACache}. Combine with `|`. */
@@ -243,9 +243,10 @@ async function executeServiceWorkerPolicy(policy) {
}
let registration = await resolveServiceWorkerRegistration();
const { scriptURL, scope } = resolveServiceWorkerMount();
if (!registration) {
registration = await navigator.serviceWorker.register(SW_PATH, {
scope: SW_SCOPE,
registration = await navigator.serviceWorker.register(scriptURL, {
scope,
updateViaCache: 'none'
});
console.log('Service Worker registered:', registration);
@@ -312,14 +313,46 @@ async function resetServiceWorkers() {
return true;
}
function resolveServiceWorkerMount() {
const fromConfig = getConfigSync(CONFIG_KEYS.APP_BASE, null);
const appBase = normalizeRouterBase(
fromConfig
?? (typeof globalThis !== 'undefined' ? globalThis.__BFACE_APP_BASE__ : null)
?? '/'
);
if (appBase === '/') {
return { scriptURL: '/sw.js', scope: '/' };
}
return {
scriptURL: `${appBase}/sw.js`,
scope: `${appBase}/`
};
}
async function resolveServiceWorkerRegistration() {
if (!hasServiceWorkerSupport()) {
return null;
}
const { scope } = resolveServiceWorkerMount();
try {
return await navigator.serviceWorker.getRegistration(SW_SCOPE)
|| await navigator.serviceWorker.getRegistration();
const scopedRegistration = await navigator.serviceWorker.getRegistration(scope);
if (scopedRegistration) {
return scopedRegistration;
}
if (scope !== '/') {
const rootRegistration = await navigator.serviceWorker.getRegistration('/');
if (rootRegistration) {
console.log('[SW] Removing stale root-scoped registration:', rootRegistration.scope);
await rootRegistration.unregister();
}
}
return null;
} catch (error) {
console.warn('[SW] Failed to read service worker registration:', error);
return null;
+3 -8
View File
@@ -6,7 +6,7 @@
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Spinner, YStack } from 'tamagui';
import { InvokeHandlers } from '../../platform/menu.js';
import { InvokeHandlers, invokeRegisteredModal } from '../../platform/menu.js';
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
@@ -883,14 +883,9 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
}
};
// Set up goToModal handler (placeholder - to be implemented)
InvokeHandlers.goToModal = (menuItem, eventSource, event) => {
if (menuItem.invoke_target) {
routerDebug('[Router] Modal not yet implemented:', menuItem.invoke_target);
// TODO: Implement modal system
} else {
console.warn('[Router] MenuItem missing invoke_target for goToModal');
}
routerDebug('[Router] Opening modal:', menuItem.invoke_target);
return invokeRegisteredModal(menuItem, eventSource, event);
};
routerDebug('[Router] InvokeHandlers configured');
+5
View File
@@ -10,6 +10,7 @@ import RecordsModel from '../../data/RecordsModel.js';
import { getIcon } from './IconMapper.jsx';
import { SidePanelShell } from './SidePanelShell.jsx';
import { networkActivityManager } from '../../platform/api.js';
import { registerModalHandler } from '../../platform/menu.js';
// ============================================================================
// Shell Context
@@ -572,6 +573,10 @@ class NotificationCenterManager {
const notificationCenterManager = new NotificationCenterManager();
registerModalHandler('notifications', () => {
notificationCenterManager.open();
});
function getNotificationTypeStyle(type = 'info') {
return {
info: {
+38
View File
@@ -0,0 +1,38 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import {
getViteBasePath,
prefixPublicAssetPath,
stripRouterBase,
withRouterBase
} from '../src/platform/compat.js';
import { initEnv, getConfigSync, CONFIG_KEYS } from '../src/platform/env.js';
describe('compat path helpers', () => {
it('normalizes vite base paths', () => {
assert.strictEqual(getViteBasePath('/'), '/');
assert.strictEqual(getViteBasePath('/admin'), '/admin/');
assert.strictEqual(getViteBasePath('/admin/'), '/admin/');
});
it('maps internal routes to public URLs and back', () => {
assert.strictEqual(withRouterBase('/home', '/admin'), '/admin/home');
assert.strictEqual(withRouterBase('/', '/admin'), '/admin/');
assert.strictEqual(stripRouterBase('/admin/home', '/admin'), '/home');
assert.strictEqual(stripRouterBase('/admin/', '/admin'), '/');
assert.strictEqual(stripRouterBase('/admin', '/admin'), '/');
assert.strictEqual(stripRouterBase('/home', '/admin'), '/');
});
it('prefixes public asset paths but not /api', () => {
assert.strictEqual(prefixPublicAssetPath('/brand-logo.svg', '/admin'), '/admin/brand-logo.svg');
assert.strictEqual(prefixPublicAssetPath('/api', '/admin'), '/api');
assert.strictEqual(prefixPublicAssetPath('/admin/logo.svg', '/admin'), '/admin/logo.svg');
});
it('seeds router base into env config', () => {
initEnv({ id: 'demo', app_base: '/admin' });
assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_BASE), '/admin');
assert.strictEqual(getConfigSync(CONFIG_KEYS.ROUTER_BASE), '/admin');
});
});
+29
View File
@@ -15,6 +15,8 @@ import {
isProduction,
isServiceWorkerEnabledSync,
resolveServiceWorkerEnabled,
resolveProfileBases,
normalizeRouterBase,
CONFIG_KEYS
} from '../src/platform/env.js';
import { getProvider } from '../src/platform/storage.js';
@@ -201,6 +203,33 @@ describe('env.js', () => {
});
});
describe('profile base paths', () => {
test('resolveProfileBases defaults to root', () => {
assert.deepStrictEqual(resolveProfileBases({}), { app_base: '/', router_base: '/' });
});
test('resolveProfileBases mirrors a single configured base', () => {
assert.deepStrictEqual(resolveProfileBases({ app_base: '/admin/' }), {
app_base: '/admin',
router_base: '/admin'
});
});
test('resolveProfileBases allows separate router base', () => {
assert.deepStrictEqual(resolveProfileBases({ app_base: '/admin', router_base: '/console' }), {
app_base: '/admin',
router_base: '/console'
});
});
test('initEnv seeds APP_BASE and ROUTER_BASE', () => {
initEnv({ id: 'demo', router_base: 'ops' });
assert.strictEqual(getConfigSync(CONFIG_KEYS.ROUTER_BASE), '/ops');
assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_BASE), '/ops');
assert.strictEqual(normalizeRouterBase('/ops/'), '/ops');
});
});
describe('isDevelopment and isProduction', () => {
test('should be functions', () => {
assert.strictEqual(typeof isDevelopment, 'function');