Skip to content

Commit

Permalink
config: Fetch config from server in frontend
Browse files Browse the repository at this point in the history
Instead of having it built into the application at webpack time, it is
fetched at load time from the server.

This adds an express route named `/config` that returns a Status with
the project configuration.

The client's entry page now needs to asynchronously fetch it from server
using this route and the page is only rendered when the response is
back. If there is an error while getting the configuration, the
MaintenancePage is displayed.

Because the initial frontend configuration is now asynchronous at the
application start, the i18n.config is converted to a function, which
initializes the i18n object that should be called once the configuration
is fetched.
  • Loading branch information
tahini committed Mar 28, 2023
1 parent 46dbc93 commit a17c33d
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2023, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import express, { RequestHandler } from 'express';
import request from 'supertest';
import * as Status from 'chaire-lib-common/lib/utils/Status';

import configRoutes from '../config.routes';

jest.mock('../../config/server.config', () => ({
mapDefaultCenter: { lon: -3, lat: -3 },
separateAdminLoginPage: false,
projectShortname: 'unitTest'
}));

const app = express();
// FIXME Since upgrading @types/node, the types are wrong and we get compilation error. It is documented for example https://github.com/DefinitelyTyped/DefinitelyTyped/issues/53584 the real fix would require upgrading a few packages and may have side-effects. Simple casting works for now.
app.use(express.json({ limit: '500mb' }) as RequestHandler);
app.use(express.urlencoded({extended: true}) as RequestHandler);
configRoutes(app);

test('Get config', async () => {
const res = await request(app)
.get(`/config`)
.set('Accept', 'application/json')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);

expect(Status.isStatusOk(res.body)).toEqual(true);
const config = Status.unwrap(res.body);
expect(config).toEqual({
mapDefaultCenter: { lon: -3, lat: -3 },
separateAdminLoginPage: false,
projectShortname: 'unitTest'
});

});
16 changes: 16 additions & 0 deletions packages/chaire-lib-backend/src/api/config.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright 2023, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import express from 'express';
// TODO This config is both server and project config. It should only include the project config to be typed later
import config from '../config/server.config';
import * as Status from 'chaire-lib-common/lib/utils/Status';

export default function (app: express.Express) {
app.get('/config', (req, res) => {
return res.status(200).json(Status.createOk(config));
});
}
1 change: 1 addition & 0 deletions packages/chaire-lib-frontend/src/components/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
export { default as LoadingPage } from './LoadingPage';
export * from './NotFoundPage';
export { default as LoginPage } from './LoginPage';
export { default as MaintenancePage } from './MaintenancePage';
84 changes: 44 additions & 40 deletions packages/chaire-lib-frontend/src/config/i18n.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,48 +14,52 @@ import config from './project.config';

const detectorOrder = config.detectLanguage ? ['cookie', 'localStorage', 'navigator'] : ['cookie', 'localStorage'];

i18n.use(LanguageDetector)
.use(initReactI18next)
.use(HttpApi)
.init(
{
detection: {
// order and from where user language should be detected
order: detectorOrder,
caches: ['localStorage', 'cookie']
const initializeI18n = () => {
i18n.use(LanguageDetector)
.use(initReactI18next)
.use(HttpApi)
.init(
{
detection: {
// order and from where user language should be detected
order: detectorOrder,
caches: ['localStorage', 'cookie']
},
load: 'languageOnly', // no region-specific,
preload: config.languages,
whitelist: config.languages,
nonExplicitSupportedLngs: false,
fallbackLng: config.defaultLocale || 'en',
debug: false,
interpolation: {
escapeValue: false // not needed for react!!
},
react: {
wait: true,
useSuspense: false
}
},
load: 'languageOnly', // no region-specific,
preload: config.languages,
whitelist: config.languages,
nonExplicitSupportedLngs: false,
fallbackLng: config.defaultLocale || 'en',
debug: false,
interpolation: {
escapeValue: false // not needed for react!!
},
react: {
wait: true,
useSuspense: false
}
},
(err, _t) => {
if (err) {
console.log(err);
(err, _t) => {
if (err) {
console.log(err);
}
}
}
);
);

if (i18n.language) {
i18n.changeLanguage(i18n.language.split('-')[0]); // force remove region specific
}

if (i18n.language) {
i18n.changeLanguage(i18n.language.split('-')[0]); // force remove region specific
}
// Make sure the currently set language exists, if any
if (i18n.language && config.languages.indexOf(i18n.language) <= -1) {
i18n.changeLanguage(config.defaultLocale);
}

// Make sure the currently set language exists, if any
if (i18n.language && config.languages.indexOf(i18n.language) <= -1) {
i18n.changeLanguage(config.defaultLocale);
}
i18n.on('languageChanged', (language) => {
document.documentElement.setAttribute('lang', language);
moment.locale(language);
});
return i18n;
};

i18n.on('languageChanged', (language) => {
document.documentElement.setAttribute('lang', language);
moment.locale(language);
});
export default i18n;
export default initializeI18n;
25 changes: 18 additions & 7 deletions packages/chaire-lib-frontend/src/config/project.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@ import config, {
ProjectConfiguration,
setProjectConfiguration
} from 'chaire-lib-common/lib/config/shared/project.config';
declare let __CONFIG__: ProjectConfiguration<unknown>;
import * as Status from 'chaire-lib-common/lib/utils/Status';

const frontendConfig = __CONFIG__;
if (frontendConfig === undefined) {
console.error('__CONFIG__ global variable is not set. Webpack should define it');
} else {
setProjectConfiguration(frontendConfig);
}
export const fetchConfiguration = async (): Promise<boolean> => {
try {
const response = await fetch('/config');
const responseStatus: Status.Status<ProjectConfiguration<unknown>> = await response.json();
if (Status.isStatusOk(responseStatus)) {
const configuration = Status.unwrap(responseStatus);
setProjectConfiguration(configuration);
return true;
} else {
console.log('Error getting configuration from server:', responseStatus.error);
return false;
}
} catch (error) {
console.log('Error getting configuration from server:', error);
return false;
}
};

export default config;
2 changes: 2 additions & 0 deletions packages/transition-backend/src/serverApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import KnexConnection from 'connect-session-knex';
import morgan from 'morgan'; // http logger
import requestIp from 'request-ip';
import authRoutes from 'chaire-lib-backend/lib/api/auth.routes';
import configRoutes from 'chaire-lib-backend/lib/api/config.routes';
import { directoryManager } from 'chaire-lib-backend/lib/utils/filesystem/directoryManager';
import { UserAttributes } from 'chaire-lib-backend/lib/services/users/user';
import config from 'chaire-lib-backend/lib/config/server.config';
Expand Down Expand Up @@ -143,6 +144,7 @@ export const setupServer = (app: Express) => {
app.use('/locales/', localePath); // this needs to be after gzip middlewares.

authRoutes(app);
configRoutes(app);

app.get('*', (req: Request, res: Response): void => {
res.sendFile(indexPath);
Expand Down
31 changes: 22 additions & 9 deletions packages/transition-frontend/src/app-transition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import { I18nextProvider } from 'react-i18next';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router-dom';

import i18n from 'chaire-lib-frontend/lib/config/i18n.config';
import initI18n from 'chaire-lib-frontend/lib/config/i18n.config';
import TransitionRouter from './components/routers/TransitionRouter';
import MainMap from './components/map/TransitionMainMap';
import configureStore from 'chaire-lib-frontend/lib/store/configureStore';
import { login, logout } from 'chaire-lib-frontend/lib/actions/Auth';
import { LoadingPage } from 'chaire-lib-frontend/lib/components/pages';
import config from 'chaire-lib-frontend/lib/config/project.config';
import { LoadingPage, MaintenancePage } from 'chaire-lib-frontend/lib/components/pages';
import config, { fetchConfiguration } from 'chaire-lib-frontend/lib/config/project.config';
import {
SupplyManagementDashboardContribution,
DemandManagementDashboardContribution
Expand All @@ -40,28 +40,41 @@ const contributions = [
new DemandManagementDashboardContribution(),
new SupplyDemandAnalysisDashboardContribution()
];
const jsx = (

let hasConfig: boolean | undefined = undefined;
let hasFetchedAuth: boolean | undefined = undefined;
let hasRendered = false;
const jsx = () => (
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<I18nextProvider i18n={initI18n()}>
<Router history={history}>
<TransitionRouter contributions={contributions} config={config} mainMap={MainMap as any} />
{hasConfig === true ? (
<TransitionRouter contributions={contributions} config={config} mainMap={MainMap as any} />
) : (
<MaintenancePage />
)}
</Router>
</I18nextProvider>
</Provider>
);

ReactDOM.render(<LoadingPage />, document.getElementById('app'));

let hasRendered = false;
const renderApp = () => {
if (!hasRendered) {
ReactDOM.render(jsx, document.getElementById('app'));
if (!hasRendered && hasConfig !== undefined && hasFetchedAuth !== undefined) {
ReactDOM.render(jsx(), document.getElementById('app'));
hasRendered = true;
}
};

fetchConfiguration().then((configOk: boolean) => {
hasConfig = configOk;
renderApp();
});

fetch('/verifyAuthentication', { credentials: 'include' })
.then((response) => {
hasFetchedAuth = true;
if (response.status === 200) {
// authorized (user authentication succeeded)
response.json().then((body) => {
Expand Down
5 changes: 1 addition & 4 deletions packages/transition-frontend/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,7 @@ module.exports = (env) => {
'CUSTOM_RASTER_TILES_XYZ_URL' : JSON.stringify(process.env.CUSTOM_RASTER_TILES_XYZ_URL || config.customRasterTilesXyzUrl),
'CUSTOM_RASTER_TILES_MIN_ZOOM': JSON.stringify(process.env.CUSTOM_RASTER_TILES_MIN_ZOOM || config.customRasterTilesMinZoom),
'CUSTOM_RASTER_TILES_MAX_ZOOM': JSON.stringify(process.env.CUSTOM_RASTER_TILES_MAX_ZOOM || config.customRasterTilesMaxZoom)
},
'__CONFIG__': JSON.stringify({
...config
})
}
}),
new webpack.optimize.AggressiveMergingPlugin(),//Merge chunks
new CompressionPlugin({
Expand Down

0 comments on commit a17c33d

Please sign in to comment.