🐛 fix(calendar): use runtime config for cross-app service URLs

Calendar app was using $env/dynamic/public for TODO and Contacts API
URLs, which doesn't work in SPA mode. Updated to use runtime config:

- Add TODO_API_URL and CONTACTS_API_URL to runtime config
- Update docker-entrypoint.sh to include new env vars
- Refactor todos.ts and birthdays.ts to use lazy-loaded clients
- Refactor user-settings.svelte.ts with lazy store initialization
- Add env vars to docker-compose.staging.yml

This fixes staging deployment where cross-app integrations were
calling localhost instead of staging URLs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-16 05:10:01 +01:00
parent 7b33f6a761
commit d2c2326ed7
8 changed files with 218 additions and 24 deletions

View file

@ -6,16 +6,22 @@ echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3016"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
echo " TODO_API_URL: $TODO_API_URL"
echo " CONTACTS_API_URL: $CONTACTS_API_URL"
# Generate config.json from environment variables
cat > /app/apps/calendar/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch contact birthdays for display
*/
import { env } from '$env/dynamic/public';
import { createApiClient } from './base-client';
import { getContactsApiUrl } from '$lib/config/runtime';
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
// Lazy-initialized client (runtime config is async)
let contactsClient: ReturnType<typeof createApiClient> | null = null;
const contactsClient = createApiClient({
baseUrl: CONTACTS_API_BASE,
apiPrefix: '/api/v1',
});
async function getContactsClient() {
if (!contactsClient) {
const contactsApiUrl = await getContactsApiUrl();
contactsClient = createApiClient({
baseUrl: contactsApiUrl,
apiPrefix: '/api/v1',
});
}
return contactsClient;
}
// ============================================
// Types for Birthday Integration
@ -61,7 +68,13 @@ interface BirthdaysResponse {
// API Functions
// ============================================
const fetchContactsApi = contactsClient.fetchApi;
async function fetchContactsApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getContactsClient();
return client.fetchApi<T>(endpoint, options);
}
/**
* Fetch all contacts with birthdays from Contacts service

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { env } from '$env/dynamic/public';
import { createApiClient, buildQueryString } from './base-client';
import { getTodoApiUrl } from '$lib/config/runtime';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// Lazy-initialized client (runtime config is async)
let todoClient: ReturnType<typeof createApiClient> | null = null;
const todoClient = createApiClient({
baseUrl: TODO_API_BASE,
apiPrefix: '/api/v1',
});
async function getTodoClient() {
if (!todoClient) {
const todoApiUrl = await getTodoApiUrl();
todoClient = createApiClient({
baseUrl: todoApiUrl,
apiPrefix: '/api/v1',
});
}
return todoClient;
}
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
@ -173,7 +180,13 @@ interface LabelsResponse {
// API Client (using shared base client)
// ============================================
const fetchTodoApi = todoClient.fetchApi;
async function fetchTodoApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getTodoClient();
return client.fetchApi<T>(endpoint, options);
}
// ============================================
// Task API Functions

View file

@ -13,6 +13,8 @@ import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
TODO_API_URL: string;
CONTACTS_API_URL: string;
}
/**
@ -22,6 +24,8 @@ export interface RuntimeConfig {
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
TODO_API_URL: z.string().url().min(1, 'TODO_API_URL must be a valid URL'),
CONTACTS_API_URL: z.string().url().min(1, 'CONTACTS_API_URL must be a valid URL'),
});
/**
@ -34,6 +38,8 @@ const ConfigSchema = z.object({
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3016',
AUTH_URL: 'http://localhost:3001',
TODO_API_URL: 'http://localhost:3018',
CONTACTS_API_URL: 'http://localhost:3015',
};
let cachedConfig: RuntimeConfig | null = null;
@ -107,6 +113,29 @@ export async function getBackendUrl(): Promise<string> {
return config.BACKEND_URL;
}
/**
* Get todo service URL
*/
export async function getTodoApiUrl(): Promise<string> {
const config = await loadConfig();
return config.TODO_API_URL;
}
/**
* Get contacts service URL
*/
export async function getContactsApiUrl(): Promise<string> {
const config = await loadConfig();
return config.CONTACTS_API_URL;
}
/**
* Get full runtime config
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Initialize configuration (call early in app lifecycle)
* This triggers the config load and caches it for subsequent calls

View file

@ -5,14 +5,17 @@
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*
* Uses lazy initialization to wait for runtime config to load.
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
import { getAuthUrl as getRuntimeAuthUrl } from '$lib/config/runtime';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
// Get auth URL with fallback for early access (before runtime config loads)
function getAuthUrlSync(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
@ -21,8 +24,128 @@ function getAuthUrl(): string {
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'calendar',
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});
// Lazy-initialized store (created after runtime config is loaded)
let _store: ReturnType<typeof createUserSettingsStore> | null = null;
function getOrCreateStore(authUrl?: string) {
if (!_store) {
_store = createUserSettingsStore({
appId: 'calendar',
authUrl: authUrl || getAuthUrlSync(),
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Ensure store is initialized with correct URL from runtime config
async function ensureStore() {
if (!_store) {
const authUrl = await getRuntimeAuthUrl();
_store = createUserSettingsStore({
appId: 'calendar',
authUrl,
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Export proxy that lazily initializes the store
export const userSettings = {
// Getters - use sync store (may have fallback URL initially)
get nav() {
return getOrCreateStore().nav;
},
get theme() {
return getOrCreateStore().theme;
},
get locale() {
return getOrCreateStore().locale;
},
get general() {
return getOrCreateStore().general;
},
get startPage() {
return getOrCreateStore().startPage;
},
get globalSettings() {
return getOrCreateStore().globalSettings;
},
get hasAppOverride() {
return getOrCreateStore().hasAppOverride;
},
get syncing() {
return getOrCreateStore().syncing;
},
get loaded() {
return getOrCreateStore().loaded;
},
get deviceId() {
return getOrCreateStore().deviceId;
},
get deviceSettings() {
return getOrCreateStore().deviceSettings;
},
get currentDeviceAppSettings() {
return getOrCreateStore().currentDeviceAppSettings;
},
// Methods that make API calls - ensure store has correct URL
async load() {
const store = await ensureStore();
return store.load();
},
async updateGlobal(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGlobal']>[0]
) {
const store = await ensureStore();
return store.updateGlobal(settings);
},
async updateAppOverride(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateAppOverride']>[0]
) {
const store = await ensureStore();
return store.updateAppOverride(settings);
},
async removeAppOverride() {
const store = await ensureStore();
return store.removeAppOverride();
},
async setStartPage(appId: string, path: string) {
const store = await ensureStore();
return store.setStartPage(appId, path);
},
async updateGeneral(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGeneral']>[0]
) {
const store = await ensureStore();
return store.updateGeneral(settings);
},
getHiddenNavItemsForApp(appId: string) {
return getOrCreateStore().getHiddenNavItemsForApp(appId);
},
async toggleNavItemVisibility(appId: string, href: string) {
const store = await ensureStore();
return store.toggleNavItemVisibility(appId, href);
},
async setHiddenNavItems(appId: string, hiddenHrefs: string[]) {
const store = await ensureStore();
return store.setHiddenNavItems(appId, hiddenHrefs);
},
async updateDeviceAppSettings(settings: Record<string, unknown>) {
const store = await ensureStore();
return store.updateDeviceAppSettings(settings);
},
getDeviceAppSettings() {
return getOrCreateStore().getDeviceAppSettings();
},
async getDevices() {
const store = await ensureStore();
return store.getDevices();
},
async removeDevice(deviceId: string) {
const store = await ensureStore();
return store.removeDevice(deviceId);
},
};

View file

@ -16,9 +16,15 @@
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig } = await import('$lib/config/runtime');
const { initializeConfig, getConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Inject config into window for stores that need synchronous access
const config = await getConfig();
(
window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }
).__PUBLIC_MANA_CORE_AUTH_URL__ = config.AUTH_URL;
// Wait for i18n locale to be loaded
await waitLocale();

View file

@ -1,4 +1,6 @@
{
"BACKEND_URL": "http://localhost:3016",
"AUTH_URL": "http://localhost:3001"
"AUTH_URL": "http://localhost:3001",
"TODO_API_URL": "http://localhost:3018",
"CONTACTS_API_URL": "http://localhost:3015"
}