mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
🔧 refactor: implement 12-factor runtime config for all web apps
Replace window injection and build-time env vars with runtime config loaded from /config.json (generated by Docker entrypoint). This fixes the staging deployment issue where apps were requesting localhost URLs instead of production URLs. Changes: - Add runtime.ts config loader with Zod validation (fail-hard in prod) - Disable SSR via +layout.ts (apps are client-only SPAs) - Update API clients and auth stores to use async config getters - Add docker-entrypoint.sh scripts to generate config.json at startup - Update Dockerfiles with ENTRYPOINT for config generation - Simplify docker-compose.staging.yml env vars (12-factor pattern) - Add static/config.json as dev fallback (localhost defaults) - Fix onMount return type (Svelte 5 compatibility) - Add zod dependency to Picture app - Add backward compat exports for Contacts app Apps updated: - Clock (port 3017) - Chat (port 3002) - Picture (port 3006) - Contacts (port 3015) - Calendar (port 3016) - Manacore (multi-app platform) Benefits: - Build once, deploy anywhere (same Docker image for all envs) - Configuration in environment, not code (12-factor compliance) - Fail-hard on missing/invalid config in production - No accidental SSR localhost fallbacks - Schema validation ensures all required URLs are present
This commit is contained in:
parent
f414aecda1
commit
2c30867251
55 changed files with 1596 additions and 610 deletions
|
|
@ -70,6 +70,10 @@ COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
|
|||
COPY --from=builder /app/apps/calendar/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script for runtime config generation
|
||||
COPY apps/calendar/apps/web/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5186
|
||||
|
||||
|
|
@ -82,5 +86,8 @@ ENV HOST=0.0.0.0
|
|||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5186/health || exit 1
|
||||
|
||||
# Use entrypoint to generate runtime config
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
|
|
|
|||
26
apps/calendar/apps/web/docker-entrypoint.sh
Normal file
26
apps/calendar/apps/web/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Generating runtime configuration..."
|
||||
|
||||
# Environment variables with development defaults
|
||||
BACKEND_URL=${BACKEND_URL:-"http://localhost:3016"}
|
||||
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
|
||||
|
||||
echo "📝 Config values:"
|
||||
echo " BACKEND_URL: $BACKEND_URL"
|
||||
echo " AUTH_URL: $AUTH_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}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Configuration generated at /app/apps/calendar/apps/web/build/client/config.json"
|
||||
cat /app/apps/calendar/apps/web/build/client/config.json
|
||||
|
||||
echo "🚀 Starting Calendar web app..."
|
||||
exec "$@"
|
||||
|
|
@ -1,25 +1,33 @@
|
|||
/**
|
||||
* API Client for Calendar Backend
|
||||
*
|
||||
* Uses runtime configuration (12-factor pattern) instead of build-time env vars.
|
||||
* Token handling: Uses authStore.getValidToken() which automatically
|
||||
* refreshes expired tokens before making requests.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getBackendUrl } from '$lib/config/runtime';
|
||||
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
|
||||
|
||||
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
let calendarClient: ReturnType<typeof createApiClient> | null = null;
|
||||
|
||||
const calendarClient = createApiClient({
|
||||
baseUrl: API_BASE,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
async function getClient() {
|
||||
if (!calendarClient) {
|
||||
const backendUrl = await getBackendUrl();
|
||||
calendarClient = createApiClient({
|
||||
baseUrl: backendUrl,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
}
|
||||
return calendarClient;
|
||||
}
|
||||
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<ApiResult<T>> {
|
||||
return calendarClient.fetchApi<T>(endpoint, options);
|
||||
const client = await getClient();
|
||||
return client.fetchApi<T>(endpoint, options);
|
||||
}
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
|
|
|
|||
116
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal file
116
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Runtime Configuration for Calendar App
|
||||
*
|
||||
* 12-Factor Pattern: Configuration is loaded from /config.json at runtime,
|
||||
* generated by Docker entrypoint from environment variables.
|
||||
* This allows the same Docker image to run in different environments
|
||||
* without rebuilding.
|
||||
*/
|
||||
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
BACKEND_URL: string;
|
||||
AUTH_URL: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema validation for config.json
|
||||
* Ensures all required configuration is present and valid
|
||||
*/
|
||||
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'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Development defaults - only used when:
|
||||
* 1. dev === true (from $app/environment)
|
||||
* 2. /config.json fetch fails
|
||||
*
|
||||
* In production, missing config.json is a deployment error.
|
||||
*/
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
BACKEND_URL: 'http://localhost:3016',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load configuration from /config.json
|
||||
* Fail-hard in production if config is missing or invalid
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Guard: SSR should never happen (we disabled it in +layout.ts)
|
||||
if (!browser) {
|
||||
if (dev) {
|
||||
console.warn('[Calendar] Config accessed during SSR in dev mode, using fallback');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error('[Calendar] Runtime config called on server - SSR should be disabled');
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
// Return existing promise if already loading
|
||||
if (configPromise) return configPromise;
|
||||
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (dev) {
|
||||
console.warn(
|
||||
`[Calendar] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
|
||||
);
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error(
|
||||
`[Calendar] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config) => {
|
||||
// Validate schema in production (fail hard on misconfiguration)
|
||||
if (!dev) {
|
||||
const result = ConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`[Calendar] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
cachedConfig = config as RuntimeConfig;
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth service URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await loadConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backend API URL
|
||||
*/
|
||||
export async function getBackendUrl(): Promise<string> {
|
||||
const config = await loadConfig();
|
||||
return config.BACKEND_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration (call early in app lifecycle)
|
||||
* This triggers the config load and caches it for subsequent calls
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
|
@ -1,45 +1,25 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
* Uses Mana Core Auth with runtime configuration (12-factor pattern)
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime - fallback for SSR and client
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
// Client-side: use injected window variable (set by hooks.server.ts)
|
||||
// Falls back to localhost for local development
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
// Server-side (SSR): use Docker internal URL for container-to-container communication
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Get backend URL dynamically at runtime
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3014';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
}
|
||||
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
async function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const authUrl = await getAuthUrl();
|
||||
const backendUrl = await getBackendUrl();
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
|
||||
baseUrl: authUrl,
|
||||
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
|
|
@ -47,10 +27,10 @@ function getAuthService() {
|
|||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
async function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
// Ensure auth service is initialized first
|
||||
getAuthService();
|
||||
await getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +60,7 @@ export const authStore = {
|
|||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
|
|
@ -107,7 +87,7 @@ export const authStore = {
|
|||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -134,7 +114,7 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
|
@ -164,7 +144,7 @@ export const authStore = {
|
|||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
|
|
@ -184,7 +164,7 @@ export const authStore = {
|
|||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -208,7 +188,7 @@ export const authStore = {
|
|||
* @deprecated Use getValidToken() instead for automatic refresh
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -220,7 +200,7 @@ export const authStore = {
|
|||
* Automatically refreshes if the token is expired or about to expire
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
const tokenManager = await getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize runtime config first (12-factor pattern)
|
||||
const { initializeConfig } = await import('$lib/config/runtime');
|
||||
await initializeConfig();
|
||||
|
||||
// Wait for i18n locale to be loaded
|
||||
await waitLocale();
|
||||
|
||||
|
|
|
|||
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal file
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Layout Configuration
|
||||
*
|
||||
* Disable SSR - this is a client-only SPA that:
|
||||
* - Requires authentication (no SEO benefit)
|
||||
* - Fetches all data client-side via authenticated APIs
|
||||
* - Loads runtime config from /config.json (browser-only)
|
||||
*/
|
||||
|
||||
export const ssr = false;
|
||||
4
apps/calendar/apps/web/static/config.json
Normal file
4
apps/calendar/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3016",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ COPY --from=builder /app/apps/chat/apps/web/node_modules ./node_modules
|
|||
COPY --from=builder /app/apps/chat/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/chat/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script for runtime config generation
|
||||
COPY apps/chat/apps/web/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
|
|
@ -80,5 +84,8 @@ ENV HOST=0.0.0.0
|
|||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
# Use entrypoint to generate runtime config
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
|
|
|
|||
26
apps/chat/apps/web/docker-entrypoint.sh
Normal file
26
apps/chat/apps/web/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Generating runtime configuration..."
|
||||
|
||||
# Environment variables with development defaults
|
||||
BACKEND_URL=${BACKEND_URL:-"http://localhost:3002"}
|
||||
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
|
||||
|
||||
echo "📝 Config values:"
|
||||
echo " BACKEND_URL: $BACKEND_URL"
|
||||
echo " AUTH_URL: $AUTH_URL"
|
||||
|
||||
# Generate config.json from environment variables
|
||||
cat > /app/apps/chat/apps/web/build/client/config.json <<EOF
|
||||
{
|
||||
"BACKEND_URL": "${BACKEND_URL}",
|
||||
"AUTH_URL": "${AUTH_URL}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Configuration generated at /app/apps/chat/apps/web/build/client/config.json"
|
||||
cat /app/apps/chat/apps/web/build/client/config.json
|
||||
|
||||
echo "🚀 Starting Chat web app..."
|
||||
exec "$@"
|
||||
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Runtime Configuration Loader
|
||||
*
|
||||
* Implements 12-factor app "Config in Environment" principle.
|
||||
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
|
||||
* allowing the same Docker image to work across all environments.
|
||||
*
|
||||
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
|
||||
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
|
||||
* - Validation: Enforces schema in production (fail hard on misconfiguration)
|
||||
* - Dev fallback: Only when dev=true, never in staging/prod
|
||||
*/
|
||||
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
BACKEND_URL: string;
|
||||
AUTH_URL: string;
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
|
||||
// Development fallback configuration (only used when dev=true)
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
BACKEND_URL: 'http://localhost:3002',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load runtime configuration from /config.json
|
||||
* Uses caching to avoid multiple fetches
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Guard: SSR should never happen (we disabled it in +layout.ts)
|
||||
if (!browser) {
|
||||
if (dev) {
|
||||
console.warn('[Chat] Config accessed during SSR in dev mode, using fallback');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error('[Chat] Runtime config called on server - SSR should be disabled');
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
// Fetch config from /config.json (generated by docker-entrypoint.sh)
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (dev) {
|
||||
console.warn(
|
||||
`[Chat] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
|
||||
);
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error(
|
||||
`[Chat] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config) => {
|
||||
// Validate schema in production (fail hard on misconfiguration)
|
||||
if (!dev) {
|
||||
const result = ConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`[Chat] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfig = config as RuntimeConfig;
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full runtime configuration
|
||||
*/
|
||||
export async function getConfig(): Promise<RuntimeConfig> {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Auth service URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Backend API URL
|
||||
*/
|
||||
export async function getBackendUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.BACKEND_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize runtime configuration
|
||||
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@
|
|||
*
|
||||
* Token handling: Uses authStore.getValidToken() which automatically
|
||||
* refreshes expired tokens before making requests.
|
||||
*
|
||||
* Uses runtime configuration for 12-factor compliance.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getBackendUrl } from '$lib/config/runtime';
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
|
|
@ -35,8 +37,6 @@ export type {
|
|||
ChatCompletionResponse,
|
||||
};
|
||||
|
||||
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
|
|
@ -56,8 +56,11 @@ async function fetchApi<T>(
|
|||
return { data: null, error: new Error('No authentication token') };
|
||||
}
|
||||
|
||||
// Get backend URL from runtime config
|
||||
const backendUrl = await getBackendUrl();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
|
||||
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -1,45 +1,24 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Now using Mana Core Auth instead of Supabase Auth
|
||||
* Uses Mana Core Auth with runtime configuration
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime - fallback for SSR and client
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
// Client-side: use injected window variable (set by hooks.server.ts)
|
||||
// Falls back to localhost for local development
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
// Server-side (SSR): use Docker internal URL for container-to-container communication
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Get backend URL dynamically at runtime
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3002';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
|
||||
}
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
async function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const authUrl = await getAuthUrl();
|
||||
const backendUrl = await getBackendUrl();
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
|
||||
baseUrl: authUrl,
|
||||
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
|
|
@ -47,10 +26,10 @@ function getAuthService() {
|
|||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
async function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
// Ensure auth service is initialized first
|
||||
getAuthService();
|
||||
await getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +59,7 @@ export const authStore = {
|
|||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
|
|
@ -107,7 +86,7 @@ export const authStore = {
|
|||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -134,7 +113,7 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
|
@ -164,7 +143,7 @@ export const authStore = {
|
|||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
|
|
@ -184,7 +163,7 @@ export const authStore = {
|
|||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -207,7 +186,7 @@ export const authStore = {
|
|||
* Get user credit balance
|
||||
*/
|
||||
async getCredits() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -226,7 +205,7 @@ export const authStore = {
|
|||
* @deprecated Use getValidToken() instead for automatic refresh
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -238,7 +217,7 @@ export const authStore = {
|
|||
* Automatically refreshes if the token is expired or about to expire
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
const tokenManager = await getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { initializeConfig } from '$lib/config/runtime';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
// Initialize runtime config first (12-factor pattern)
|
||||
await initializeConfig();
|
||||
|
||||
// Initialize theme
|
||||
const cleanup = theme.initialize();
|
||||
return cleanup;
|
||||
});
|
||||
|
|
|
|||
10
apps/chat/apps/web/src/routes/+layout.ts
Normal file
10
apps/chat/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Layout Configuration
|
||||
*
|
||||
* Disable SSR - this is a client-only SPA that:
|
||||
* - Requires authentication (no SEO benefit)
|
||||
* - Fetches all data client-side via authenticated APIs
|
||||
* - Loads runtime config from /config.json (browser-only)
|
||||
*/
|
||||
|
||||
export const ssr = false;
|
||||
4
apps/chat/apps/web/static/config.json
Normal file
4
apps/chat/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3002",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
|
|||
COPY --from=builder /app/apps/clock/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/clock/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script for runtime config generation
|
||||
COPY apps/clock/apps/web/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5187
|
||||
|
||||
|
|
@ -80,5 +84,8 @@ ENV HOST=0.0.0.0
|
|||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
|
||||
|
||||
# Use entrypoint to generate runtime config
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
|
|
|
|||
26
apps/clock/apps/web/docker-entrypoint.sh
Normal file
26
apps/clock/apps/web/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Generating runtime configuration..."
|
||||
|
||||
# Environment variables with development defaults
|
||||
API_BASE_URL=${API_BASE_URL:-"http://localhost:3017"}
|
||||
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
|
||||
|
||||
echo "📝 Config values:"
|
||||
echo " API_BASE_URL: $API_BASE_URL"
|
||||
echo " AUTH_URL: $AUTH_URL"
|
||||
|
||||
# Generate config.json from environment variables
|
||||
cat > /app/apps/clock/apps/web/build/client/config.json <<EOF
|
||||
{
|
||||
"API_BASE_URL": "${API_BASE_URL}",
|
||||
"AUTH_URL": "${AUTH_URL}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Configuration generated at /app/apps/clock/apps/web/build/client/config.json"
|
||||
cat /app/apps/clock/apps/web/build/client/config.json
|
||||
|
||||
echo "🚀 Starting Clock web app..."
|
||||
exec "$@"
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* API Client for Clock backend
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_URL = 'http://localhost:3017/api/v1';
|
||||
import { getApiBaseUrl } from '$lib/config/runtime';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
|
|
@ -17,6 +17,7 @@ export async function fetchApi<T>(
|
|||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const token = await authStore.getAccessToken();
|
||||
const apiBaseUrl = await getApiBaseUrl();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -27,7 +28,7 @@ export async function fetchApi<T>(
|
|||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
|
|
|||
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Runtime Configuration Loader
|
||||
*
|
||||
* Implements 12-factor app "Config in Environment" principle.
|
||||
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
|
||||
* allowing the same Docker image to work across all environments.
|
||||
*
|
||||
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
|
||||
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
|
||||
* - Validation: Enforces schema in production (fail hard on misconfiguration)
|
||||
* - Dev fallback: Only when dev=true, never in staging/prod
|
||||
*/
|
||||
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
API_BASE_URL: string;
|
||||
AUTH_URL: string;
|
||||
}
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
API_BASE_URL: z.string().url().min(1, 'API_BASE_URL must be a valid URL'),
|
||||
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
|
||||
});
|
||||
|
||||
// Development fallback configuration (only used when dev=true)
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
API_BASE_URL: 'http://localhost:3017',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load runtime configuration from /config.json
|
||||
* Uses caching to avoid multiple fetches
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Guard: SSR should never happen (we disabled it in +layout.ts)
|
||||
if (!browser) {
|
||||
if (dev) {
|
||||
console.warn('[Clock] Config accessed during SSR in dev mode, using fallback');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error('[Clock] Runtime config called on server - SSR should be disabled');
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
// Fetch config from /config.json (generated by docker-entrypoint.sh)
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (dev) {
|
||||
console.warn(
|
||||
`[Clock] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
|
||||
);
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error(
|
||||
`[Clock] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config) => {
|
||||
// Validate schema in production (fail hard on misconfiguration)
|
||||
if (!dev) {
|
||||
const result = ConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`[Clock] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfig = config as RuntimeConfig;
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full runtime configuration
|
||||
*/
|
||||
export async function getConfig(): Promise<RuntimeConfig> {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Auth service URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API base URL
|
||||
*/
|
||||
export async function getApiBaseUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.API_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize runtime configuration
|
||||
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
|
@ -1,44 +1,24 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
* Uses Mana Core Auth with runtime configuration
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime - fallback for SSR and client
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
// Client-side: use injected window variable (set by hooks.server.ts)
|
||||
// Falls back to localhost for local development
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
// Server-side (SSR): use Docker internal URL for container-to-container communication
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Get backend URL dynamically at runtime
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3017';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
|
||||
}
|
||||
import { getAuthUrl, getApiBaseUrl } from '$lib/config/runtime';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
async function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const authUrl = await getAuthUrl();
|
||||
const backendUrl = await getApiBaseUrl();
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
|
||||
baseUrl: authUrl,
|
||||
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
|
|
@ -46,10 +26,10 @@ function getAuthService() {
|
|||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
async function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
// Ensure auth service is initialized first
|
||||
getAuthService();
|
||||
await getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +59,7 @@ export const authStore = {
|
|||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
|
|
@ -106,7 +86,7 @@ export const authStore = {
|
|||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -133,7 +113,7 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
|
@ -163,7 +143,7 @@ export const authStore = {
|
|||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
|
|
@ -183,7 +163,7 @@ export const authStore = {
|
|||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -207,7 +187,7 @@ export const authStore = {
|
|||
* @deprecated Use getValidToken() instead for automatic refresh
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -219,7 +199,7 @@ export const authStore = {
|
|||
* Automatically refreshes if the token is expired or about to expire
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
const tokenManager = await getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { initializeConfig } from '$lib/config/runtime';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
|
|
@ -13,6 +14,9 @@
|
|||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize runtime config first (12-factor pattern)
|
||||
await initializeConfig();
|
||||
|
||||
// Wait for locale to be loaded
|
||||
await waitLocale();
|
||||
|
||||
|
|
|
|||
10
apps/clock/apps/web/src/routes/+layout.ts
Normal file
10
apps/clock/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Layout Configuration
|
||||
*
|
||||
* Disable SSR - this is a client-only SPA that:
|
||||
* - Requires authentication (no SEO benefit)
|
||||
* - Fetches all data client-side via authenticated APIs
|
||||
* - Loads runtime config from /config.json (browser-only)
|
||||
*/
|
||||
|
||||
export const ssr = false;
|
||||
4
apps/clock/apps/web/static/config.json
Normal file
4
apps/clock/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"API_BASE_URL": "http://localhost:3017",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
94
apps/contacts/apps/web/Dockerfile
Normal file
94
apps/contacts/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars
|
||||
ARG PUBLIC_BACKEND_URL=http://contacts-backend:3015
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages needed by contacts web
|
||||
COPY packages/shared-auth ./packages/shared-auth
|
||||
COPY packages/shared-auth-ui ./packages/shared-auth-ui
|
||||
COPY packages/shared-branding ./packages/shared-branding
|
||||
COPY packages/shared-feedback-service ./packages/shared-feedback-service
|
||||
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
|
||||
COPY packages/shared-help-content ./packages/shared-help-content
|
||||
COPY packages/shared-help-types ./packages/shared-help-types
|
||||
COPY packages/shared-help-ui ./packages/shared-help-ui
|
||||
COPY packages/shared-i18n ./packages/shared-i18n
|
||||
COPY packages/shared-icons ./packages/shared-icons
|
||||
COPY packages/shared-profile-ui ./packages/shared-profile-ui
|
||||
COPY packages/shared-splitscreen ./packages/shared-splitscreen
|
||||
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
|
||||
COPY packages/shared-tags ./packages/shared-tags
|
||||
COPY packages/shared-tailwind ./packages/shared-tailwind
|
||||
COPY packages/shared-theme ./packages/shared-theme
|
||||
COPY packages/shared-theme-ui ./packages/shared-theme-ui
|
||||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
|
||||
# Copy contacts packages
|
||||
COPY apps/contacts/packages ./apps/contacts/packages
|
||||
COPY apps/contacts/apps/web ./apps/contacts/apps/web
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared packages that need building
|
||||
WORKDIR /app/packages/shared-auth
|
||||
RUN pnpm build || true
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/contacts/apps/web
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||
WORKDIR /app/apps/contacts/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
||||
COPY --from=builder /app/apps/contacts/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/contacts/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/contacts/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script for runtime config generation
|
||||
COPY apps/contacts/apps/web/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
# Use entrypoint to generate runtime config
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
26
apps/contacts/apps/web/docker-entrypoint.sh
Normal file
26
apps/contacts/apps/web/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Generating runtime configuration..."
|
||||
|
||||
# Environment variables with development defaults
|
||||
BACKEND_URL=${BACKEND_URL:-"http://localhost:3015"}
|
||||
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
|
||||
|
||||
echo "📝 Config values:"
|
||||
echo " BACKEND_URL: $BACKEND_URL"
|
||||
echo " AUTH_URL: $AUTH_URL"
|
||||
|
||||
# Generate config.json from environment variables
|
||||
cat > /app/apps/contacts/apps/web/build/client/config.json <<EOF
|
||||
{
|
||||
"BACKEND_URL": "${BACKEND_URL}",
|
||||
"AUTH_URL": "${AUTH_URL}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Configuration generated at /app/apps/contacts/apps/web/build/client/config.json"
|
||||
cat /app/apps/contacts/apps/web/build/client/config.json
|
||||
|
||||
echo "🚀 Starting Contacts web app..."
|
||||
exec "$@"
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* Centralized API client with authentication
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
import { getApiBase } from './config';
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
|
|
@ -16,6 +17,7 @@ export async function fetchWithAuth<T = unknown>(
|
|||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const apiBase = await getApiBase();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -26,7 +28,7 @@ export async function fetchWithAuth<T = unknown>(
|
|||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
const response = await fetch(`${apiBase}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
|
@ -48,6 +50,7 @@ export async function fetchWithAuthFormData<T = unknown>(
|
|||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const apiBase = await getApiBase();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
...(options.headers || {}),
|
||||
|
|
@ -57,7 +60,7 @@ export async function fetchWithAuthFormData<T = unknown>(
|
|||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
const response = await fetch(`${apiBase}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,33 @@
|
|||
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
|
||||
/**
|
||||
* API Configuration
|
||||
* Uses environment variables with fallbacks for development
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
|
||||
|
||||
import { getBackendUrl, getAuthUrl } from '$lib/config/runtime';
|
||||
|
||||
/**
|
||||
* Mana Core Auth URL
|
||||
* Central authentication service URL
|
||||
* Get API base URL with /api/v1 suffix
|
||||
*/
|
||||
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
export async function getApiBase(): Promise<string> {
|
||||
const backendUrl = await getBackendUrl();
|
||||
return `${backendUrl}/api/v1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Mana Core Auth URL
|
||||
*/
|
||||
export async function getManaAuthUrl(): Promise<string> {
|
||||
return await getAuthUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getApiBase() instead for runtime config
|
||||
* This export is kept for backward compatibility
|
||||
*/
|
||||
export const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
/**
|
||||
* @deprecated Use getManaAuthUrl() instead for runtime config
|
||||
* This export is kept for backward compatibility
|
||||
*/
|
||||
export const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
|
|
|||
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Runtime Configuration Loader
|
||||
*
|
||||
* Implements 12-factor app "Config in Environment" principle.
|
||||
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
|
||||
* allowing the same Docker image to work across all environments.
|
||||
*
|
||||
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
|
||||
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
|
||||
* - Validation: Enforces schema in production (fail hard on misconfiguration)
|
||||
* - Dev fallback: Only when dev=true, never in staging/prod
|
||||
*/
|
||||
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
BACKEND_URL: string;
|
||||
AUTH_URL: string;
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
|
||||
// Development fallback configuration (only used when dev=true)
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
BACKEND_URL: 'http://localhost:3015',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load runtime configuration from /config.json
|
||||
* Uses caching to avoid multiple fetches
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Guard: SSR should never happen (we disabled it in +layout.ts)
|
||||
if (!browser) {
|
||||
if (dev) {
|
||||
console.warn('[Contacts] Config accessed during SSR in dev mode, using fallback');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error('[Contacts] Runtime config called on server - SSR should be disabled');
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
// Fetch config from /config.json (generated by docker-entrypoint.sh)
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (dev) {
|
||||
console.warn(
|
||||
`[Contacts] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
|
||||
);
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error(
|
||||
`[Contacts] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config) => {
|
||||
// Validate schema in production (fail hard on misconfiguration)
|
||||
if (!dev) {
|
||||
const result = ConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`[Contacts] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfig = config as RuntimeConfig;
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full runtime configuration
|
||||
*/
|
||||
export async function getConfig(): Promise<RuntimeConfig> {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Auth service URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Backend API URL
|
||||
*/
|
||||
export async function getBackendUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.BACKEND_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize runtime configuration
|
||||
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
|
@ -1,45 +1,24 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
* Uses Mana Core Auth with runtime configuration
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime - fallback for SSR and client
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
// Client-side: use injected window variable (set by hooks.server.ts)
|
||||
// Falls back to localhost for local development
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
// Server-side (SSR): use Docker internal URL for container-to-container communication
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Get backend URL dynamically at runtime
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3015';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3015';
|
||||
}
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
async function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const authUrl = await getAuthUrl();
|
||||
const backendUrl = await getBackendUrl();
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
|
||||
baseUrl: authUrl,
|
||||
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
|
|
@ -47,10 +26,10 @@ function getAuthService() {
|
|||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
async function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
// Ensure auth service is initialized first
|
||||
getAuthService();
|
||||
await getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +59,7 @@ export const authStore = {
|
|||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
|
|
@ -107,7 +86,7 @@ export const authStore = {
|
|||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -134,7 +113,7 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
|
@ -164,7 +143,7 @@ export const authStore = {
|
|||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
|
|
@ -184,7 +163,7 @@ export const authStore = {
|
|||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -208,7 +187,7 @@ export const authStore = {
|
|||
* @deprecated Use getValidToken() instead for automatic refresh
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -220,7 +199,7 @@ export const authStore = {
|
|||
* Automatically refreshes if the token is expired or about to expire
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
const tokenManager = await getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize runtime config first (12-factor pattern)
|
||||
const { initializeConfig } = await import('$lib/config/runtime');
|
||||
await initializeConfig();
|
||||
|
||||
// Setup global error handling
|
||||
setupGlobalErrorHandling();
|
||||
|
||||
|
|
|
|||
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal file
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Layout Configuration
|
||||
*
|
||||
* Disable SSR - this is a client-only SPA that:
|
||||
* - Requires authentication (no SEO benefit)
|
||||
* - Fetches all data client-side via authenticated APIs
|
||||
* - Loads runtime config from /config.json (browser-only)
|
||||
*/
|
||||
|
||||
export const ssr = false;
|
||||
4
apps/contacts/apps/web/static/config.json
Normal file
4
apps/contacts/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3015",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -69,6 +69,10 @@ COPY --from=builder /app/apps/manacore/apps/web/node_modules ./node_modules
|
|||
COPY --from=builder /app/apps/manacore/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/manacore/apps/web/package.json ./
|
||||
|
||||
# Copy entrypoint script for runtime config generation
|
||||
COPY apps/manacore/apps/web/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
|
|
@ -81,5 +85,8 @@ ENV HOST=0.0.0.0
|
|||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5173/health || exit 1
|
||||
|
||||
# Use entrypoint to generate runtime config
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
|
|
|
|||
37
apps/manacore/apps/web/docker-entrypoint.sh
Executable file
37
apps/manacore/apps/web/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Docker Entrypoint for Manacore Web
|
||||
# Generates runtime config from environment variables
|
||||
# Implements "build once, configure at runtime" pattern
|
||||
|
||||
echo "🔧 Generating runtime configuration..."
|
||||
|
||||
# Default values for local development
|
||||
API_BASE_URL=${API_BASE_URL:-"http://localhost:5173"}
|
||||
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
|
||||
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
|
||||
CALENDAR_API_URL=${CALENDAR_API_URL:-"http://localhost:3016"}
|
||||
CLOCK_API_URL=${CLOCK_API_URL:-"http://localhost:3017"}
|
||||
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
|
||||
|
||||
# Generate config.json from template
|
||||
cat > /app/build/client/config.json <<EOF
|
||||
{
|
||||
"API_BASE_URL": "${API_BASE_URL}",
|
||||
"AUTH_URL": "${AUTH_URL}",
|
||||
"TODO_API_URL": "${TODO_API_URL}",
|
||||
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
|
||||
"CLOCK_API_URL": "${CLOCK_API_URL}",
|
||||
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Runtime configuration generated:"
|
||||
cat /app/build/client/config.json
|
||||
|
||||
echo ""
|
||||
echo "🚀 Starting Node server..."
|
||||
|
||||
# Execute the CMD (node build)
|
||||
exec "$@"
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Credits Service for ManaCore Web App
|
||||
* Handles credit balance, transactions, and packages
|
||||
*
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
|
||||
import { getAuthUrl } from '$lib/config/runtime';
|
||||
|
||||
// Types
|
||||
export interface CreditBalance {
|
||||
|
|
@ -52,8 +53,9 @@ export interface CreditPurchase {
|
|||
// Helper function for authenticated requests
|
||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const authUrl = await getAuthUrl();
|
||||
|
||||
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
|
||||
const response = await fetch(`${authUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -99,7 +101,8 @@ export const creditsService = {
|
|||
* Get available credit packages (public endpoint)
|
||||
*/
|
||||
async getPackages(): Promise<CreditPackage[]> {
|
||||
const response = await fetch(`${MANA_AUTH_URL}/api/v1/credits/packages`);
|
||||
const authUrl = await getAuthUrl();
|
||||
const response = await fetch(`${authUrl}/api/v1/credits/packages`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch packages');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
/**
|
||||
* Feedback Service Instance for ManaCore Web App
|
||||
*
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getAuthUrl } from '$lib/config/runtime';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
|
||||
// Lazy initialization to allow runtime config to load first
|
||||
let _feedbackService: ReturnType<typeof createFeedbackService> | null = null;
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: MANA_AUTH_URL,
|
||||
appId: 'manacore',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
async function getFeedbackService() {
|
||||
if (!_feedbackService) {
|
||||
const authUrl = await getAuthUrl();
|
||||
_feedbackService = createFeedbackService({
|
||||
apiUrl: authUrl,
|
||||
appId: 'manacore',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
}
|
||||
return _feedbackService;
|
||||
}
|
||||
|
||||
// Export the async getter for components
|
||||
export { getFeedbackService as getService };
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Referrals Service for ManaCore Web App
|
||||
* Handles referral codes, stats, and referral tracking
|
||||
*
|
||||
* Uses runtime configuration for 12-factor compliance
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
|
||||
import { getAuthUrl } from '$lib/config/runtime';
|
||||
|
||||
// Types
|
||||
export interface ReferralStats {
|
||||
|
|
@ -54,8 +55,9 @@ export interface ReferralValidation {
|
|||
// Helper function for authenticated requests
|
||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const authUrl = await getAuthUrl();
|
||||
|
||||
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
|
||||
const response = await fetch(`${authUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -109,7 +111,8 @@ export const referralsService = {
|
|||
*/
|
||||
async validateCode(code: string): Promise<ReferralValidation> {
|
||||
try {
|
||||
const response = await fetch(`${MANA_AUTH_URL}/api/v1/referrals/validate/${code}`);
|
||||
const authUrl = await getAuthUrl();
|
||||
const response = await fetch(`${authUrl}/api/v1/referrals/validate/${code}`);
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: 'Invalid code' };
|
||||
}
|
||||
|
|
|
|||
16
apps/manacore/apps/web/src/lib/config/api.ts
Normal file
16
apps/manacore/apps/web/src/lib/config/api.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* API Configuration (Legacy - Deprecated)
|
||||
*
|
||||
* @deprecated Use runtime.ts instead for proper 12-factor config
|
||||
* This file is kept for backward compatibility during migration
|
||||
*/
|
||||
|
||||
import { getAuthUrl as getRuntimeAuthUrl } from './runtime';
|
||||
|
||||
/**
|
||||
* Get the Mana Core Auth URL dynamically at runtime
|
||||
* @deprecated Use getAuthUrl() from './runtime' instead
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
return getRuntimeAuthUrl();
|
||||
}
|
||||
117
apps/manacore/apps/web/src/lib/config/runtime.ts
Normal file
117
apps/manacore/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Runtime Configuration Loader
|
||||
*
|
||||
* Implements the 12-factor "config in environment" principle.
|
||||
* Loads configuration from /config.json at runtime, allowing the same
|
||||
* Docker image to be deployed across dev/staging/prod environments.
|
||||
*
|
||||
* Pattern: Build once, configure at runtime
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
API_BASE_URL: string;
|
||||
AUTH_URL: string;
|
||||
TODO_API_URL: string;
|
||||
CALENDAR_API_URL: string;
|
||||
CLOCK_API_URL: string;
|
||||
CONTACTS_API_URL: string;
|
||||
}
|
||||
|
||||
// Development fallbacks (only used in local dev, not in Docker)
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
API_BASE_URL: 'http://localhost:5173',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
TODO_API_URL: 'http://localhost:3018',
|
||||
CALENDAR_API_URL: 'http://localhost:3016',
|
||||
CLOCK_API_URL: 'http://localhost:3017',
|
||||
CONTACTS_API_URL: 'http://localhost:3015',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load runtime configuration from /config.json
|
||||
* This file is generated by the Docker entrypoint script from environment variables
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Server-side: use dev config (SSR doesn't need runtime config)
|
||||
if (!browser) {
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// Return existing promise if loading
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
// Load config from /config.json
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
console.warn('Failed to load /config.json, using dev config');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config: RuntimeConfig) => {
|
||||
cachedConfig = config;
|
||||
return config;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Error loading runtime config:', error);
|
||||
return DEV_CONFIG;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime configuration
|
||||
* Must be called after app initialization
|
||||
*/
|
||||
export async function getConfig(): Promise<RuntimeConfig> {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get synchronous config (only use after initialization)
|
||||
* Throws if config not loaded yet
|
||||
*/
|
||||
export function getConfigSync(): RuntimeConfig {
|
||||
if (!cachedConfig) {
|
||||
throw new Error('Runtime config not loaded. Call getConfig() first.');
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize runtime config on app start
|
||||
* Call this in your root +layout.svelte
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get auth URL (most commonly used)
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get backend URL
|
||||
*/
|
||||
export async function getApiBaseUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.API_BASE_URL;
|
||||
}
|
||||
|
|
@ -1,43 +1,32 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
* Uses Mana Core Auth with runtime configuration
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime - fallback for SSR and client
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
// Client-side: use injected window variable (set by hooks.server.ts)
|
||||
// Falls back to localhost for local development
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
// Server-side (SSR): use Docker internal URL for container-to-container communication
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
import { getAuthUrl } from '$lib/config/runtime';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
async function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
|
||||
const authUrl = await getAuthUrl();
|
||||
const auth = initializeWebAuth({ baseUrl: authUrl });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
async function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
// Ensure auth service is initialized first
|
||||
getAuthService();
|
||||
await getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +56,7 @@ export const authStore = {
|
|||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
|
|
@ -94,7 +83,7 @@ export const authStore = {
|
|||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -133,7 +122,7 @@ export const authStore = {
|
|||
* @param referralCode Optional referral code for bonus credits
|
||||
*/
|
||||
async signUp(email: string, password: string, referralCode?: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
|
@ -161,6 +150,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Validate a referral code
|
||||
* @deprecated Use referralsService.validateCode() instead
|
||||
*/
|
||||
async validateReferralCode(code: string) {
|
||||
try {
|
||||
|
|
@ -184,7 +174,7 @@ export const authStore = {
|
|||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
|
|
@ -204,7 +194,7 @@ export const authStore = {
|
|||
* Send password reset email
|
||||
*/
|
||||
async forgotPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -227,7 +217,7 @@ export const authStore = {
|
|||
* Reset password with token
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
|
@ -251,7 +241,7 @@ export const authStore = {
|
|||
* @deprecated Use getValidToken() instead for automatic refresh
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
const authService = await getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -263,7 +253,7 @@ export const authStore = {
|
|||
* Automatically refreshes if the token is expired or about to expire
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
const tokenManager = await getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { getService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
|
||||
let feedbackService = $state<ReturnType<typeof createFeedbackService> | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
feedbackService = await getService();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FeedbackPage {feedbackService} appName="ManaCore" currentUserId={authStore.user?.id} />
|
||||
{#if feedbackService}
|
||||
<FeedbackPage {feedbackService} appName="ManaCore" currentUserId={authStore.user?.id} />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,23 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { initializeConfig } from '$lib/config/runtime';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Initialize theme
|
||||
const cleanupTheme = theme.initialize();
|
||||
// Initialize runtime config first (12-factor config pattern)
|
||||
initializeConfig().then(() => {
|
||||
// Initialize theme
|
||||
const cleanupTheme = theme.initialize();
|
||||
|
||||
// Initialize auth (non-blocking)
|
||||
authStore.initialize();
|
||||
// Initialize auth (non-blocking)
|
||||
authStore.initialize();
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
cleanupTheme();
|
||||
// Theme cleanup will be handled when theme is initialized
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
8
apps/manacore/apps/web/static/config.json
Normal file
8
apps/manacore/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"API_BASE_URL": "http://localhost:5173",
|
||||
"AUTH_URL": "http://localhost:3001",
|
||||
"TODO_API_URL": "http://localhost:3018",
|
||||
"CALENDAR_API_URL": "http://localhost:3016",
|
||||
"CLOCK_API_URL": "http://localhost:3017",
|
||||
"CONTACTS_API_URL": "http://localhost:3015"
|
||||
}
|
||||
8
apps/manacore/apps/web/static/config.json.template
Normal file
8
apps/manacore/apps/web/static/config.json.template
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"API_BASE_URL": "${API_BASE_URL}",
|
||||
"AUTH_URL": "${AUTH_URL}",
|
||||
"TODO_API_URL": "${TODO_API_URL}",
|
||||
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
|
||||
"CLOCK_API_URL": "${CLOCK_API_URL}",
|
||||
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
|
||||
}
|
||||
|
|
@ -34,7 +34,8 @@
|
|||
"@picture/shared": "workspace:*",
|
||||
"konva": "^10.0.2",
|
||||
"posthog-js": "^1.273.1",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
* - Uses authStore.getValidToken() which automatically refreshes expired tokens
|
||||
* - The fetch interceptor (setupFetchInterceptor) handles 401 responses by refreshing and retrying
|
||||
* - If refresh fails, the request fails and user should be redirected to login
|
||||
*
|
||||
* Uses runtime configuration for 12-factor compliance.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
|
||||
import { getBackendUrl } from '$lib/config/runtime';
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
|
|
@ -29,6 +29,9 @@ export async function fetchApi<T>(
|
|||
// Get a valid token (auto-refreshes if expired)
|
||||
const authToken = token || (await authStore.getValidToken());
|
||||
|
||||
// Get backend URL from runtime config
|
||||
const backendUrl = await getBackendUrl();
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
|
|
@ -41,7 +44,7 @@ export async function fetchApi<T>(
|
|||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
|
||||
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
|
||||
|
|
@ -81,6 +84,9 @@ export async function uploadFile(
|
|||
// Get a valid token (auto-refreshes if expired)
|
||||
const authToken = token || (await authStore.getValidToken());
|
||||
|
||||
// Get backend URL from runtime config
|
||||
const backendUrl = await getBackendUrl();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
|
@ -90,7 +96,7 @@ export async function uploadFile(
|
|||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
|
||||
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
|
|
@ -125,6 +131,9 @@ export async function uploadFiles(
|
|||
// Get a valid token (auto-refreshes if expired)
|
||||
const authToken = token || (await authStore.getValidToken());
|
||||
|
||||
// Get backend URL from runtime config
|
||||
const backendUrl = await getBackendUrl();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
|
|
@ -136,7 +145,7 @@ export async function uploadFiles(
|
|||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
|
||||
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
|
|
|
|||
124
apps/picture/apps/web/src/lib/config/runtime.ts
Normal file
124
apps/picture/apps/web/src/lib/config/runtime.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Runtime Configuration Loader
|
||||
*
|
||||
* Implements 12-factor app "Config in Environment" principle.
|
||||
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
|
||||
* allowing the same Docker image to work across all environments.
|
||||
*
|
||||
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
|
||||
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
|
||||
* - Validation: Enforces schema in production (fail hard on misconfiguration)
|
||||
* - Dev fallback: Only when dev=true, never in staging/prod
|
||||
*/
|
||||
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
BACKEND_URL: string;
|
||||
AUTH_URL: string;
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
|
||||
// Development fallback configuration (only used when dev=true)
|
||||
const DEV_CONFIG: RuntimeConfig = {
|
||||
BACKEND_URL: 'http://localhost:3006',
|
||||
AUTH_URL: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
let cachedConfig: RuntimeConfig | null = null;
|
||||
let configPromise: Promise<RuntimeConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Load runtime configuration from /config.json
|
||||
* Uses caching to avoid multiple fetches
|
||||
*/
|
||||
async function loadConfig(): Promise<RuntimeConfig> {
|
||||
// Guard: SSR should never happen (we disabled it in +layout.ts)
|
||||
if (!browser) {
|
||||
if (dev) {
|
||||
console.warn('[Picture] Config accessed during SSR in dev mode, using fallback');
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error('[Picture] Runtime config called on server - SSR should be disabled');
|
||||
}
|
||||
|
||||
// Return cached config if available
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
// Fetch config from /config.json (generated by docker-entrypoint.sh)
|
||||
configPromise = fetch('/config.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (dev) {
|
||||
console.warn(
|
||||
`[Picture] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
|
||||
);
|
||||
return DEV_CONFIG;
|
||||
}
|
||||
throw new Error(
|
||||
`[Picture] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((config) => {
|
||||
// Validate schema in production (fail hard on misconfiguration)
|
||||
if (!dev) {
|
||||
const result = ConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors
|
||||
.map((e: { path: (string | number)[]; message: string }) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
throw new Error(`[Picture] Invalid config.json schema: ${errors}`);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfig = config as RuntimeConfig;
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full runtime configuration
|
||||
*/
|
||||
export async function getConfig(): Promise<RuntimeConfig> {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Auth service URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.AUTH_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Backend API URL
|
||||
*/
|
||||
export async function getBackendUrl(): Promise<string> {
|
||||
const config = await getConfig();
|
||||
return config.BACKEND_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize runtime configuration
|
||||
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
|
||||
*/
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
await loadConfig();
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Now using Mana Core Auth instead of Supabase Auth
|
||||
* Uses Mana Core Auth with runtime configuration
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const MANA_AUTH_URL = env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
|
||||
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
|
||||
|
||||
export interface UserData {
|
||||
id: string;
|
||||
|
|
@ -28,10 +25,12 @@ async function getAuthService() {
|
|||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
try {
|
||||
const authUrl = await getAuthUrl();
|
||||
const backendUrl = await getBackendUrl();
|
||||
const { initializeWebAuth } = await import('@manacore/shared-auth');
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: MANA_AUTH_URL,
|
||||
backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses
|
||||
baseUrl: authUrl,
|
||||
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import Toast from '$lib/components/ui/Toast.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { initPostHog, analytics } from '$lib/analytics/posthog';
|
||||
import { initializeConfig } from '$lib/config/runtime';
|
||||
|
||||
// Import and initialize theme
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
|
@ -15,25 +16,28 @@
|
|||
let { children, data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Initialize theme (applies CSS variables and loads from localStorage)
|
||||
const cleanupTheme = theme.initialize();
|
||||
// Initialize runtime config first (12-factor pattern)
|
||||
initializeConfig().then(() => {
|
||||
// Initialize theme (applies CSS variables and loads from localStorage)
|
||||
const cleanupTheme = theme.initialize();
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
|
||||
// Initialize auth with Mana Core
|
||||
authStore.initialize().then(() => {
|
||||
// Identify user in PostHog if logged in
|
||||
if (authStore.user) {
|
||||
analytics.identify(authStore.user.id, {
|
||||
email: authStore.user.email,
|
||||
});
|
||||
}
|
||||
// Initialize auth with Mana Core
|
||||
authStore.initialize().then(() => {
|
||||
// Identify user in PostHog if logged in
|
||||
if (authStore.user) {
|
||||
analytics.identify(authStore.user.id, {
|
||||
email: authStore.user.email,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupTheme();
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupTheme();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
10
apps/picture/apps/web/src/routes/+layout.ts
Normal file
10
apps/picture/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Layout Configuration
|
||||
*
|
||||
* Disable SSR - this is a client-only SPA that:
|
||||
* - Requires authentication (no SEO benefit)
|
||||
* - Fetches all data client-side via authenticated APIs
|
||||
* - Loads runtime config from /config.json (browser-only)
|
||||
*/
|
||||
|
||||
export const ssr = false;
|
||||
4
apps/picture/apps/web/static/config.json
Normal file
4
apps/picture/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3006",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue