Skip to content

Commit 060d99b

Browse files
Use permanent cache for translation files on production (#181377)
## Summary Fix #83409 Use a permanent cache (`public, max-age=365d, immutable`) for translation files when in production (`dist`), similar to what we're doing for static assets. Translation files cache busting is a little tricky, because it doesn't only depend on the version (enabling or disabling a custom plugin can change the translations while not changing the build hash), so we're using a custom hash generated from the content of the current translation file (which was already used to generate the `etag` header previously). --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent d4a9132 commit 060d99b

File tree

18 files changed

+209
-82
lines changed

18 files changed

+209
-82
lines changed

packages/core/i18n/core-i18n-server-internal/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
* Side Public License, v 1.
77
*/
88

9-
export type { I18nConfigType } from './src';
9+
export type { I18nConfigType, InternalI18nServicePreboot } from './src';
1010
export { config, I18nService } from './src';

packages/core/i18n/core-i18n-server-internal/src/i18n_service.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ describe('I18nService', () => {
3737
let configService: ReturnType<typeof configServiceMock.create>;
3838
let httpPreboot: ReturnType<typeof httpServiceMock.createInternalPrebootContract>;
3939
let httpSetup: ReturnType<typeof httpServiceMock.createInternalSetupContract>;
40+
let coreContext: ReturnType<typeof mockCoreContext.create>;
4041

4142
beforeEach(() => {
4243
jest.clearAllMocks();
4344
configService = getConfigService();
4445

45-
const coreContext = mockCoreContext.create({ configService });
46+
coreContext = mockCoreContext.create({ configService });
4647
service = new I18nService(coreContext);
4748

4849
httpPreboot = httpServiceMock.createInternalPrebootContract();
@@ -73,13 +74,15 @@ describe('I18nService', () => {
7374
expect(initTranslationsMock).toHaveBeenCalledWith('en', translationFiles);
7475
});
7576

76-
it('calls `registerRoutesMock` with the correct parameters', async () => {
77+
it('calls `registerRoutes` with the correct parameters', async () => {
7778
await service.preboot({ pluginPaths: [], http: httpPreboot });
7879

7980
expect(registerRoutesMock).toHaveBeenCalledTimes(1);
8081
expect(registerRoutesMock).toHaveBeenCalledWith({
8182
locale: 'en',
8283
router: expect.any(Object),
84+
isDist: coreContext.env.packageInfo.dist,
85+
translationHash: expect.any(String),
8386
});
8487
});
8588
});
@@ -114,13 +117,15 @@ describe('I18nService', () => {
114117
expect(initTranslationsMock).toHaveBeenCalledWith('en', translationFiles);
115118
});
116119

117-
it('calls `registerRoutesMock` with the correct parameters', async () => {
120+
it('calls `registerRoutes` with the correct parameters', async () => {
118121
await service.setup({ pluginPaths: [], http: httpSetup });
119122

120123
expect(registerRoutesMock).toHaveBeenCalledTimes(1);
121124
expect(registerRoutesMock).toHaveBeenCalledWith({
122125
locale: 'en',
123126
router: expect.any(Object),
127+
isDist: coreContext.env.packageInfo.dist,
128+
translationHash: expect.any(String),
124129
});
125130
});
126131

packages/core/i18n/core-i18n-server-internal/src/i18n_service.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88

99
import { firstValueFrom } from 'rxjs';
10+
import { createHash } from 'crypto';
11+
import { i18n, Translation } from '@kbn/i18n';
1012
import type { Logger } from '@kbn/logging';
1113
import type { IConfigService } from '@kbn/config';
1214
import type { CoreContext } from '@kbn/core-base-server-internal';
@@ -30,29 +32,42 @@ export interface SetupDeps {
3032
pluginPaths: string[];
3133
}
3234

35+
export interface InternalI18nServicePreboot {
36+
getTranslationHash(): string;
37+
}
38+
3339
export class I18nService {
3440
private readonly log: Logger;
3541
private readonly configService: IConfigService;
3642

37-
constructor(coreContext: CoreContext) {
43+
constructor(private readonly coreContext: CoreContext) {
3844
this.log = coreContext.logger.get('i18n');
3945
this.configService = coreContext.configService;
4046
}
4147

42-
public async preboot({ pluginPaths, http }: PrebootDeps) {
43-
const { locale } = await this.initTranslations(pluginPaths);
44-
http.registerRoutes('', (router) => registerRoutes({ router, locale }));
48+
public async preboot({ pluginPaths, http }: PrebootDeps): Promise<InternalI18nServicePreboot> {
49+
const { locale, translationHash } = await this.initTranslations(pluginPaths);
50+
const { dist: isDist } = this.coreContext.env.packageInfo;
51+
http.registerRoutes('', (router) =>
52+
registerRoutes({ router, locale, isDist, translationHash })
53+
);
54+
55+
return {
56+
getTranslationHash: () => translationHash,
57+
};
4558
}
4659

4760
public async setup({ pluginPaths, http }: SetupDeps): Promise<I18nServiceSetup> {
48-
const { locale, translationFiles } = await this.initTranslations(pluginPaths);
61+
const { locale, translationFiles, translationHash } = await this.initTranslations(pluginPaths);
4962

5063
const router = http.createRouter('');
51-
registerRoutes({ router, locale });
64+
const { dist: isDist } = this.coreContext.env.packageInfo;
65+
registerRoutes({ router, locale, isDist, translationHash });
5266

5367
return {
5468
getLocale: () => locale,
5569
getTranslationFiles: () => translationFiles,
70+
getTranslationHash: () => translationHash,
5671
};
5772
}
5873

@@ -69,6 +84,13 @@ export class I18nService {
6984
this.log.debug(`Using translation files: [${translationFiles.join(', ')}]`);
7085
await initTranslations(locale, translationFiles);
7186

72-
return { locale, translationFiles };
87+
const translationHash = getTranslationHash(i18n.getTranslation());
88+
89+
return { locale, translationFiles, translationHash };
7390
}
7491
}
92+
93+
const getTranslationHash = (translations: Translation) => {
94+
const serialized = JSON.stringify(translations);
95+
return createHash('sha256').update(serialized).digest('hex').slice(0, 12);
96+
};

packages/core/i18n/core-i18n-server-internal/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
export { config } from './i18n_config';
1010
export type { I18nConfigType } from './i18n_config';
1111
export { I18nService } from './i18n_service';
12+
export type { InternalI18nServicePreboot } from './i18n_service';

packages/core/i18n/core-i18n-server-internal/src/routes/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
import type { IRouter } from '@kbn/core-http-server';
1010
import { registerTranslationsRoute } from './translations';
1111

12-
export const registerRoutes = ({ router, locale }: { router: IRouter; locale: string }) => {
13-
registerTranslationsRoute(router, locale);
12+
export const registerRoutes = ({
13+
router,
14+
locale,
15+
isDist,
16+
translationHash,
17+
}: {
18+
router: IRouter;
19+
locale: string;
20+
isDist: boolean;
21+
translationHash: string;
22+
}) => {
23+
registerTranslationsRoute({ router, locale, isDist, translationHash });
1424
};

packages/core/i18n/core-i18n-server-internal/src/routes/translations.test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,27 @@ import { registerTranslationsRoute } from './translations';
1212
describe('registerTranslationsRoute', () => {
1313
test('registers route with expected options', () => {
1414
const router = mockRouter.create();
15-
registerTranslationsRoute(router, 'en');
16-
expect(router.get).toHaveBeenCalledTimes(1);
15+
registerTranslationsRoute({
16+
router,
17+
locale: 'en',
18+
isDist: true,
19+
translationHash: 'XXXX',
20+
});
21+
expect(router.get).toHaveBeenCalledTimes(2);
1722
expect(router.get).toHaveBeenNthCalledWith(
1823
1,
19-
expect.objectContaining({ options: { access: 'public', authRequired: false } }),
24+
expect.objectContaining({
25+
path: '/translations/{locale}.json',
26+
options: { access: 'public', authRequired: false },
27+
}),
28+
expect.any(Function)
29+
);
30+
expect(router.get).toHaveBeenNthCalledWith(
31+
2,
32+
expect.objectContaining({
33+
path: '/translations/XXXX/{locale}.json',
34+
options: { access: 'public', authRequired: false },
35+
}),
2036
expect.any(Function)
2137
);
2238
});

packages/core/i18n/core-i18n-server-internal/src/routes/translations.ts

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,54 +6,81 @@
66
* Side Public License, v 1.
77
*/
88

9-
import { createHash } from 'crypto';
109
import { i18n } from '@kbn/i18n';
1110
import { schema } from '@kbn/config-schema';
1211
import type { IRouter } from '@kbn/core-http-server';
1312

13+
const MINUTE = 60;
14+
const HOUR = 60 * MINUTE;
15+
const DAY = 24 * HOUR;
16+
1417
interface TranslationCache {
1518
translations: string;
1619
hash: string;
1720
}
1821

19-
export const registerTranslationsRoute = (router: IRouter, locale: string) => {
22+
export const registerTranslationsRoute = ({
23+
router,
24+
locale,
25+
translationHash,
26+
isDist,
27+
}: {
28+
router: IRouter;
29+
locale: string;
30+
translationHash: string;
31+
isDist: boolean;
32+
}) => {
2033
let translationCache: TranslationCache;
2134

22-
router.get(
23-
{
24-
path: '/translations/{locale}.json',
25-
validate: {
26-
params: schema.object({
27-
locale: schema.string(),
28-
}),
29-
},
30-
options: {
31-
access: 'public',
32-
authRequired: false,
33-
},
34-
},
35-
(ctx, req, res) => {
36-
if (req.params.locale.toLowerCase() !== locale.toLowerCase()) {
37-
return res.notFound({
38-
body: `Unknown locale: ${req.params.locale}`,
39-
});
40-
}
41-
if (!translationCache) {
42-
const translations = JSON.stringify(i18n.getTranslation());
43-
const hash = createHash('sha1').update(translations).digest('hex');
44-
translationCache = {
45-
translations,
46-
hash,
47-
};
48-
}
49-
return res.ok({
50-
headers: {
51-
'content-type': 'application/json',
52-
'cache-control': 'must-revalidate',
53-
etag: translationCache.hash,
35+
['/translations/{locale}.json', `/translations/${translationHash}/{locale}.json`].forEach(
36+
(routePath) => {
37+
router.get(
38+
{
39+
path: routePath,
40+
validate: {
41+
params: schema.object({
42+
locale: schema.string(),
43+
}),
44+
},
45+
options: {
46+
access: 'public',
47+
authRequired: false,
48+
},
5449
},
55-
body: translationCache.translations,
56-
});
50+
(ctx, req, res) => {
51+
if (req.params.locale.toLowerCase() !== locale.toLowerCase()) {
52+
return res.notFound({
53+
body: `Unknown locale: ${req.params.locale}`,
54+
});
55+
}
56+
if (!translationCache) {
57+
const translations = JSON.stringify(i18n.getTranslation());
58+
translationCache = {
59+
translations,
60+
hash: translationHash,
61+
};
62+
}
63+
64+
let headers: Record<string, string>;
65+
if (isDist) {
66+
headers = {
67+
'content-type': 'application/json',
68+
'cache-control': `public, max-age=${365 * DAY}, immutable`,
69+
};
70+
} else {
71+
headers = {
72+
'content-type': 'application/json',
73+
'cache-control': 'must-revalidate',
74+
etag: translationCache.hash,
75+
};
76+
}
77+
78+
return res.ok({
79+
headers,
80+
body: translationCache.translations,
81+
});
82+
}
83+
);
5784
}
5885
);
5986
};

packages/core/i18n/core-i18n-server-mocks/src/i18n_service.mock.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,29 @@
77
*/
88

99
import type { PublicMethodsOf } from '@kbn/utility-types';
10-
import type { I18nService } from '@kbn/core-i18n-server-internal';
10+
import type { I18nService, InternalI18nServicePreboot } from '@kbn/core-i18n-server-internal';
1111
import type { I18nServiceSetup } from '@kbn/core-i18n-server';
1212

1313
const createSetupContractMock = () => {
1414
const mock: jest.Mocked<I18nServiceSetup> = {
1515
getLocale: jest.fn(),
1616
getTranslationFiles: jest.fn(),
17+
getTranslationHash: jest.fn(),
1718
};
1819

1920
mock.getLocale.mockReturnValue('en');
2021
mock.getTranslationFiles.mockReturnValue([]);
22+
mock.getTranslationHash.mockReturnValue('MOCK_HASH');
23+
24+
return mock;
25+
};
26+
27+
const createInternalPrebootMock = () => {
28+
const mock: jest.Mocked<InternalI18nServicePreboot> = {
29+
getTranslationHash: jest.fn(),
30+
};
31+
32+
mock.getTranslationHash.mockReturnValue('MOCK_HASH');
2133

2234
return mock;
2335
};
@@ -38,4 +50,5 @@ const createMock = () => {
3850
export const i18nServiceMock = {
3951
create: createMock,
4052
createSetupContract: createSetupContractMock,
53+
createInternalPrebootContract: createInternalPrebootMock,
4154
};

packages/core/i18n/core-i18n-server/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,9 @@ export interface I18nServiceSetup {
1919
* Return the absolute paths to translation files currently in use.
2020
*/
2121
getTranslationFiles(): string[];
22+
23+
/**
24+
* Returns the hash generated from the current translations.
25+
*/
26+
getTranslationHash(): string;
2227
}

0 commit comments

Comments
 (0)