Skip to content

Commit e7ac654

Browse files
authored
Merge pull request #893 from nsemets/fix/ssr-throttle-token
Added throttle token and updated config load
2 parents aaeb9cb + 494fc22 commit e7ac654

20 files changed

Lines changed: 260 additions & 161 deletions

Dockerfile

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,37 @@
1-
# Build
2-
FROM node:22-alpine AS build
3-
1+
# Dependencies stage
2+
FROM node:22-alpine AS deps
43
WORKDIR /app
5-
64
COPY package*.json ./
7-
RUN npm install
5+
RUN npm ci --no-audit --no-fund
86

7+
# Build stage (SSR build output)
8+
FROM deps AS build
99
COPY . .
10+
RUN NG_BUILD_OPTIMIZE_CHUNKS=1 npx ng build --configuration=ssr --verbose
1011

11-
RUN npm link @angular/cli
12-
RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --verbose
13-
14-
# Dist
15-
FROM node:22-alpine AS dist
16-
17-
WORKDIR /code
18-
19-
COPY --from=build /app/dist /code/dist
20-
21-
# SSR
22-
FROM node:22-alpine AS ssr
23-
12+
# SSR runtime stage
13+
FROM build AS ssr
2414
WORKDIR /app
25-
26-
COPY package*.json ./
27-
RUN npm install
28-
29-
COPY . .
30-
31-
RUN npm link @angular/cli
32-
RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --configuration=ssr --verbose
33-
34-
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
35-
15+
RUN npm prune --omit=dev --no-audit --no-fund
3616
EXPOSE 4000
37-
3817
ENV PORT=4000
39-
4018
CMD ["node", "dist/osf/server/server.mjs"]
4119

42-
# Dev - run only
43-
FROM build AS dev
20+
# Static dist artifact stage
21+
FROM node:22-alpine AS dist
22+
WORKDIR /code
23+
COPY --from=build /app/dist /code/dist
4424

25+
# Dev server stage
26+
FROM deps AS dev
27+
COPY . .
4528
EXPOSE 4200
29+
CMD ["npx", "ng", "serve", "--host", "0.0.0.0"]
4630

47-
CMD ["ng", "serve"]
48-
49-
# Local Development - coding
31+
# Local development stage
5032
FROM node:22-alpine AS local-dev
5133
WORKDIR /app
52-
53-
# Install deps in the image (kept in container)
5434
COPY package*.json ./
55-
# COPY package-lock.docker.json ./package-lock.json
5635
RUN npm ci --no-audit --no-fund
57-
58-
# Expose Angular dev server
5936
EXPOSE 4200
37+
CMD ["npx", "ng", "serve", "--host", "0.0.0.0"]

angular.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@
117117
"namedChunks": true
118118
},
119119
"development": {
120+
"outputMode": "static",
121+
"server": false,
122+
"ssr": false,
123+
"optimization": false,
124+
"extractLicenses": false,
125+
"sourceMap": true,
126+
"fileReplacements": [
127+
{
128+
"replace": "src/environments/environment.ts",
129+
"with": "src/environments/environment.development.ts"
130+
}
131+
]
132+
},
133+
"dev-ssr": {
120134
"optimization": false,
121135
"extractLicenses": false,
122136
"sourceMap": true,
@@ -164,6 +178,9 @@
164178
"development": {
165179
"buildTarget": "osf:build:development"
166180
},
181+
"dev-ssr": {
182+
"buildTarget": "osf:build:dev-ssr"
183+
},
167184
"docker": {
168185
"buildTarget": "osf:build:docker"
169186
},

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"ngxs:store": "ng generate @ngxs/store:store --name --path",
2121
"prepare": "husky",
2222
"start": "ng serve",
23+
"start:ssr": "ng serve --configuration dev-ssr",
2324
"start:docker": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration development",
2425
"start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker",
2526
"test": "jest",

src/@types/ace-builds.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module 'ace-builds/src-noconflict/ext-language_tools';

src/app/app.config.server.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,42 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
22
import { provideServerRendering } from '@angular/platform-server';
33
import { provideServerRouting } from '@angular/ssr';
44

5+
import { SSR_CONFIG } from '@core/constants/ssr-config.token';
6+
import { ConfigModel } from '@core/models/config.model';
7+
58
import { appConfig } from './app.config';
69
import { serverRoutes } from './app.routes.server';
710

11+
import { existsSync, readFileSync } from 'node:fs';
12+
import { dirname, resolve } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
function loadSsrConfig(): ConfigModel {
16+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
17+
const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json');
18+
19+
let config = {} as ConfigModel;
20+
21+
if (existsSync(configPath)) {
22+
try {
23+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
24+
} catch {
25+
config = {} as ConfigModel;
26+
}
27+
}
28+
29+
return {
30+
...config,
31+
throttleToken: process.env['THROTTLE_TOKEN'] || '',
32+
} as ConfigModel;
33+
}
34+
835
const serverConfig: ApplicationConfig = {
9-
providers: [provideServerRendering(), provideServerRouting(serverRoutes)],
36+
providers: [
37+
provideServerRendering(),
38+
provideServerRouting(serverRoutes),
39+
{ provide: SSR_CONFIG, useFactory: loadSsrConfig },
40+
],
1041
};
1142

1243
export const config = mergeApplicationConfig(appConfig, serverConfig);

src/app/app.routes.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ export const serverRoutes: ServerRoute[] = [
141141
path: ':id/metadata/:recordId',
142142
renderMode: RenderMode.Server,
143143
},
144+
{
145+
path: ':id/wiki',
146+
renderMode: RenderMode.Server,
147+
},
144148
{
145149
path: ':id/files/**',
146150
renderMode: RenderMode.Server,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
import { ConfigModel } from '@core/models/config.model';
4+
5+
export const SSR_CONFIG = new InjectionToken<ConfigModel>('SSR_CONFIG');

src/app/core/helpers/i18n.helper.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { TranslateLoader, TranslateModuleConfig } from '@ngx-translate/core';
22
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
33

4+
import { isPlatformServer } from '@angular/common';
45
import { HttpClient } from '@angular/common/http';
6+
import { inject, PLATFORM_ID } from '@angular/core';
7+
8+
import { ENVIRONMENT } from '@core/provider/environment.provider';
59

610
function httpLoaderFactory(http: HttpClient): TranslateHttpLoader {
7-
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
11+
const platformId = inject(PLATFORM_ID);
12+
const environment = inject(ENVIRONMENT);
13+
const basePrefix = '/assets/i18n/';
14+
const webUrl = environment.webUrl?.replace(/\/+$/, '') ?? '';
15+
const prefix = isPlatformServer(platformId) && webUrl ? `${webUrl}${basePrefix}` : basePrefix;
16+
17+
return new TranslateHttpLoader(http, prefix, '.json');
818
}
919

1020
export const provideTranslation = (): TranslateModuleConfig => ({

src/app/core/interceptors/auth.interceptor.spec.ts

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,33 @@ import { MockProvider } from 'ng-mocks';
44
import { of } from 'rxjs';
55

66
import { HttpRequest } from '@angular/common/http';
7-
import { runInInjectionContext } from '@angular/core';
7+
import { PLATFORM_ID, runInInjectionContext } from '@angular/core';
88
import { TestBed } from '@angular/core/testing';
99

10+
import { ENVIRONMENT } from '@core/provider/environment.provider';
11+
import { EnvironmentModel } from '@osf/shared/models/environment.model';
12+
1013
import { authInterceptor } from './auth.interceptor';
1114

1215
describe('authInterceptor', () => {
1316
let cookieService: CookieService;
14-
let mockHandler: jest.Mock;
17+
let cookieServiceMock: { get: jest.Mock };
1518

16-
beforeEach(() => {
17-
mockHandler = jest.fn();
19+
const setup = (platformId = 'browser', environmentOverrides: Partial<EnvironmentModel> = {}) => {
20+
cookieServiceMock = { get: jest.fn() };
1821

1922
TestBed.configureTestingModule({
2023
providers: [
21-
MockProvider(CookieService, {
22-
get: jest.fn(),
23-
}),
24-
{
25-
provide: 'PLATFORM_ID',
26-
useValue: 'browser',
27-
},
28-
{
29-
provide: 'REQUEST',
30-
useValue: null,
31-
},
24+
MockProvider(CookieService, cookieServiceMock),
25+
MockProvider(PLATFORM_ID, platformId),
26+
MockProvider(ENVIRONMENT, { throttleToken: '', ...environmentOverrides } as EnvironmentModel),
3227
],
3328
});
3429

3530
cookieService = TestBed.inject(CookieService);
31+
};
32+
33+
beforeEach(() => {
3634
jest.clearAllMocks();
3735
});
3836

@@ -44,11 +42,12 @@ describe('authInterceptor', () => {
4442
};
4543

4644
const createHandler = () => {
47-
const handler = mockHandler.mockReturnValue(of({}));
45+
const handler = jest.fn().mockReturnValue(of({}));
4846
return handler;
4947
};
5048

5149
it('should skip ROR funders API requests', () => {
50+
setup();
5251
const request = createRequest('https://api.ror.org/v2');
5352
const handler = createHandler();
5453

@@ -60,6 +59,7 @@ describe('authInterceptor', () => {
6059
});
6160

6261
it('should set Accept header to */* for text response type', () => {
62+
setup();
6363
const request = createRequest('/api/v2/projects/', { responseType: 'text' });
6464
const handler = createHandler();
6565

@@ -71,6 +71,7 @@ describe('authInterceptor', () => {
7171
});
7272

7373
it('should set Accept header to API version for json response type', () => {
74+
setup();
7475
const request = createRequest('/api/v2/projects/', { responseType: 'json' });
7576
const handler = createHandler();
7677

@@ -82,6 +83,7 @@ describe('authInterceptor', () => {
8283
});
8384

8485
it('should set Content-Type header when not present', () => {
86+
setup();
8587
const request = createRequest('/api/v2/projects/');
8688
const handler = createHandler();
8789

@@ -93,6 +95,7 @@ describe('authInterceptor', () => {
9395
});
9496

9597
it('should not override existing Content-Type header', () => {
98+
setup();
9699
const request = createRequest('/api/v2/projects/');
97100
const requestWithHeaders = request.clone({
98101
setHeaders: { 'Content-Type': 'application/json' },
@@ -107,7 +110,8 @@ describe('authInterceptor', () => {
107110
});
108111

109112
it('should add CSRF token and withCredentials in browser platform', () => {
110-
jest.spyOn(cookieService, 'get').mockReturnValue('csrf-token-123');
113+
setup();
114+
cookieServiceMock.get.mockReturnValue('csrf-token-123');
111115

112116
const request = createRequest('/api/v2/projects/');
113117
const handler = createHandler();
@@ -122,7 +126,8 @@ describe('authInterceptor', () => {
122126
});
123127

124128
it('should not add CSRF token when not available in browser platform', () => {
125-
jest.spyOn(cookieService, 'get').mockReturnValue('');
129+
setup();
130+
cookieServiceMock.get.mockReturnValue('');
126131

127132
const request = createRequest('/api/v2/projects/');
128133
const handler = createHandler();
@@ -135,4 +140,37 @@ describe('authInterceptor', () => {
135140
expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false);
136141
expect(modifiedRequest.withCredentials).toBe(true);
137142
});
143+
144+
it('should not add X-Throttle-Token on browser platform', () => {
145+
setup('browser', { throttleToken: 'test-token' });
146+
const request = createRequest('/api/v2/projects/');
147+
const handler = createHandler();
148+
149+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
150+
151+
const modifiedRequest = handler.mock.calls[0][0];
152+
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
153+
});
154+
155+
it('should add X-Throttle-Token on server platform when token is present', () => {
156+
setup('server', { throttleToken: 'test-token' });
157+
const request = createRequest('/api/v2/projects/');
158+
const handler = createHandler();
159+
160+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
161+
162+
const modifiedRequest = handler.mock.calls[0][0];
163+
expect(modifiedRequest.headers.get('X-Throttle-Token')).toBe('test-token');
164+
});
165+
166+
it('should not add X-Throttle-Token on server platform when token is empty', () => {
167+
setup('server', { throttleToken: '' });
168+
const request = createRequest('/api/v2/projects/');
169+
const handler = createHandler();
170+
171+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
172+
173+
const modifiedRequest = handler.mock.calls[0][0];
174+
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
175+
});
138176
});

0 commit comments

Comments
 (0)