Skip to content

Commit 03e9e1f

Browse files
committed
feat(client): add runtime-config startup injection for single-image multi-environment deployment
1 parent e1fc8f2 commit 03e9e1f

10 files changed

Lines changed: 196 additions & 20 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ jobs:
3131
context: .
3232
file: Dockerfile
3333
build-args: |
34-
BUILD_CONFIGURATION=docker
34+
BUILD_CONFIGURATION=docker-local
3535
platforms: linux/amd64,linux/arm64
3636
push: true
3737
tags: |
3838
diogopro/taskmanagement-client:latest
39+
diogopro/taskmanagement-client:localhost
3940
diogopro/taskmanagement-client:sha-${{ github.sha }}

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ RUN npm ci
77

88
COPY . .
99

10-
ARG BUILD_CONFIGURATION=docker
10+
ARG BUILD_CONFIGURATION=docker-local
1111
RUN npm run build -- --configuration=${BUILD_CONFIGURATION}
1212

1313
FROM nginx:1.27-alpine AS runtime
1414

1515
COPY nginx.conf /etc/nginx/conf.d/default.conf
1616
COPY --from=build /app/dist/task-management-client/browser /usr/share/nginx/html
17+
COPY docker/docker-entrypoint.d/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
1718

1819
EXPOSE 80
1920

angular.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,29 @@
7878
"fileReplacements": [
7979
{
8080
"replace": "src/environments/environment.ts",
81-
"with": "src/environments/environment.docker.ts"
81+
"with": "src/environments/environment.docker.prod.ts"
82+
}
83+
]
84+
},
85+
"docker-local": {
86+
"optimization": false,
87+
"extractLicenses": false,
88+
"sourceMap": false,
89+
"fileReplacements": [
90+
{
91+
"replace": "src/environments/environment.ts",
92+
"with": "src/environments/environment.docker.local.ts"
93+
}
94+
]
95+
},
96+
"docker-prod": {
97+
"optimization": false,
98+
"extractLicenses": false,
99+
"sourceMap": false,
100+
"fileReplacements": [
101+
{
102+
"replace": "src/environments/environment.ts",
103+
"with": "src/environments/environment.docker.prod.ts"
82104
}
83105
]
84106
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env sh
2+
set -eu
3+
4+
APP_BASE_URL="${APP_BASE_URL:-https://app.localhost}"
5+
API_BASE_URL="${API_BASE_URL:-https://api.localhost}"
6+
AUTH_AUTHORITY="${AUTH_AUTHORITY:-${AUTH_ISSUER:-https://auth.localhost}}"
7+
8+
APP_ALLOWED_HOST="${APP_ALLOWED_HOST:-$(printf '%s' "$APP_BASE_URL" | sed -E 's#^https?://([^/]+).*$#\1#')}"
9+
10+
TEMPLATE_PATH="/usr/share/nginx/html/assets/runtime-config.template.json"
11+
OUTPUT_PATH="/usr/share/nginx/html/assets/runtime-config.json"
12+
13+
if [ -f "$TEMPLATE_PATH" ]; then
14+
sed \
15+
-e "s#__APP_BASE_URL__#${APP_BASE_URL}#g" \
16+
-e "s#__API_BASE_URL__#${API_BASE_URL}#g" \
17+
-e "s#__AUTH_API_BASE_URL__#${AUTH_AUTHORITY}#g" \
18+
-e "s#__AUTH_AUTHORITY__#${AUTH_AUTHORITY}#g" \
19+
-e "s#__APP_ALLOWED_HOST__#${APP_ALLOWED_HOST}#g" \
20+
"$TEMPLATE_PATH" > "$OUTPUT_PATH"
21+
fi

src/app/app.config.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,25 @@ import { provideAnimations } from '@angular/platform-browser/animations';
77
import { ConfirmationService, MessageService } from 'primeng/api';
88
import { environment } from '../environments/environment';
99
import { APP_ENVIRONMENT } from './core/config/app-environment.token';
10+
import { AppEnvironment } from './core/config/app-environment';
1011
import { authInterceptor } from './core/auth/interceptors/auth.interceptor';
1112
import { problemDetailsInterceptor } from './core/http/interceptors/problem-details.interceptor';
1213

13-
export const appConfig: ApplicationConfig = {
14-
providers: [
15-
provideZoneChangeDetection({ eventCoalescing: true }),
16-
provideRouter(routes),
17-
provideHttpClient(withInterceptors([authInterceptor, problemDetailsInterceptor])),
18-
provideAnimations(),
19-
ConfirmationService,
20-
MessageService,
21-
{
22-
provide: APP_ENVIRONMENT,
23-
useValue: environment
24-
}
25-
]
26-
};
14+
export function buildAppConfig(appEnvironment: AppEnvironment): ApplicationConfig {
15+
return {
16+
providers: [
17+
provideZoneChangeDetection({ eventCoalescing: true }),
18+
provideRouter(routes),
19+
provideHttpClient(withInterceptors([authInterceptor, problemDetailsInterceptor])),
20+
provideAnimations(),
21+
ConfirmationService,
22+
MessageService,
23+
{
24+
provide: APP_ENVIRONMENT,
25+
useValue: appEnvironment
26+
}
27+
]
28+
};
29+
}
30+
31+
export const appConfig: ApplicationConfig = buildAppConfig(environment);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AppEnvironment } from './app-environment';
2+
3+
interface RuntimeEnvironmentOverrides {
4+
production?: boolean;
5+
apiBaseUrl?: string;
6+
authApiBaseUrl?: string;
7+
activityHubPath?: string;
8+
debugAuth?: {
9+
enabled?: boolean;
10+
allowedHosts?: string[];
11+
};
12+
auth?: {
13+
authority?: string;
14+
clientId?: string;
15+
redirectUri?: string;
16+
postLogoutRedirectUri?: string;
17+
responseType?: 'code';
18+
scopes?: string[];
19+
};
20+
}
21+
22+
export async function loadRuntimeEnvironment(baseEnvironment: AppEnvironment): Promise<AppEnvironment> {
23+
try {
24+
const response = await fetch('/assets/runtime-config.json', { cache: 'no-store' });
25+
if (!response.ok) {
26+
return baseEnvironment;
27+
}
28+
29+
const overrides = (await response.json()) as RuntimeEnvironmentOverrides;
30+
return mergeEnvironment(baseEnvironment, overrides);
31+
} catch {
32+
return baseEnvironment;
33+
}
34+
}
35+
36+
function mergeEnvironment(base: AppEnvironment, overrides: RuntimeEnvironmentOverrides): AppEnvironment {
37+
return {
38+
production: typeof overrides.production === 'boolean' ? overrides.production : base.production,
39+
apiBaseUrl: normalizeString(overrides.apiBaseUrl, base.apiBaseUrl),
40+
authApiBaseUrl: normalizeString(overrides.authApiBaseUrl, base.authApiBaseUrl),
41+
activityHubPath: normalizeString(overrides.activityHubPath, base.activityHubPath),
42+
debugAuth: {
43+
enabled: typeof overrides.debugAuth?.enabled === 'boolean' ? overrides.debugAuth.enabled : base.debugAuth.enabled,
44+
allowedHosts:
45+
Array.isArray(overrides.debugAuth?.allowedHosts) && overrides.debugAuth.allowedHosts.length > 0
46+
? overrides.debugAuth.allowedHosts
47+
: base.debugAuth.allowedHosts
48+
},
49+
auth: {
50+
authority: normalizeString(overrides.auth?.authority, base.auth.authority),
51+
clientId: normalizeString(overrides.auth?.clientId, base.auth.clientId),
52+
redirectUri: normalizeString(overrides.auth?.redirectUri, base.auth.redirectUri),
53+
postLogoutRedirectUri: normalizeString(overrides.auth?.postLogoutRedirectUri, base.auth.postLogoutRedirectUri),
54+
responseType: overrides.auth?.responseType ?? base.auth.responseType,
55+
scopes: Array.isArray(overrides.auth?.scopes) && overrides.auth.scopes.length > 0 ? overrides.auth.scopes : base.auth.scopes
56+
}
57+
};
58+
}
59+
60+
function normalizeString(value: string | undefined, fallback: string): string {
61+
return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
62+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"production": false,
3+
"apiBaseUrl": "__API_BASE_URL__",
4+
"authApiBaseUrl": "__AUTH_API_BASE_URL__",
5+
"activityHubPath": "/hubs/activity",
6+
"debugAuth": {
7+
"enabled": false,
8+
"allowedHosts": ["__APP_ALLOWED_HOST__"]
9+
},
10+
"auth": {
11+
"authority": "__AUTH_AUTHORITY__",
12+
"clientId": "angular-client",
13+
"redirectUri": "__APP_BASE_URL__/callback",
14+
"postLogoutRedirectUri": "__APP_BASE_URL__",
15+
"responseType": "code",
16+
"scopes": ["openid", "profile", "email", "roles", "api1"]
17+
}
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { AppEnvironment } from '../app/core/config/app-environment';
2+
3+
export const environment: AppEnvironment = {
4+
production: false,
5+
apiBaseUrl: 'https://api.localhost',
6+
authApiBaseUrl: 'https://auth.localhost',
7+
activityHubPath: '/hubs/activity',
8+
debugAuth: {
9+
enabled: false,
10+
allowedHosts: ['app.localhost']
11+
},
12+
auth: {
13+
authority: 'https://auth.localhost',
14+
clientId: 'angular-client',
15+
redirectUri: 'https://app.localhost/callback',
16+
postLogoutRedirectUri: 'https://app.localhost',
17+
responseType: 'code',
18+
scopes: ['openid', 'profile', 'email', 'roles', 'api1']
19+
}
20+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { AppEnvironment } from '../app/core/config/app-environment';
2+
3+
export const environment: AppEnvironment = {
4+
production: false,
5+
apiBaseUrl: 'https://api.144.24.250.76.nip.io',
6+
authApiBaseUrl: 'https://auth.144.24.250.76.nip.io',
7+
activityHubPath: '/hubs/activity',
8+
debugAuth: {
9+
enabled: false,
10+
allowedHosts: ['app.144.24.250.76.nip.io']
11+
},
12+
auth: {
13+
authority: 'https://auth.144.24.250.76.nip.io',
14+
clientId: 'angular-client',
15+
redirectUri: 'https://app.144.24.250.76.nip.io/callback',
16+
postLogoutRedirectUri: 'https://app.144.24.250.76.nip.io',
17+
responseType: 'code',
18+
scopes: ['openid', 'profile', 'email', 'roles', 'api1']
19+
}
20+
};

src/main.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { bootstrapApplication } from '@angular/platform-browser';
2-
import { appConfig } from './app/app.config';
2+
import { buildAppConfig } from './app/app.config';
33
import { AppComponent } from './app/app.component';
4+
import { environment } from './environments/environment';
5+
import { loadRuntimeEnvironment } from './app/core/config/runtime-environment.loader';
46

5-
bootstrapApplication(AppComponent, appConfig)
6-
.catch((err) => console.error(err));
7+
async function bootstrap(): Promise<void> {
8+
const runtimeEnvironment = await loadRuntimeEnvironment(environment);
9+
await bootstrapApplication(AppComponent, buildAppConfig(runtimeEnvironment));
10+
}
11+
12+
void bootstrap().catch((err) => console.error(err));

0 commit comments

Comments
 (0)