feat(l10n): add localizer class for payments next

Because:

* Create a localizer class that can be used by payments next react
  server components.
* Only generate fluent bundles once on startup.

This commit:

* Moves logic from Localizer class, defined in
  `fxa-auth-server/lib/l10n/index.ts`, to a LocalizerBase class.
* Adds LocalizerServer class to be used by payments next.
* Moves nestapp from payments-next app to a library

Closes #FXA-8821
This commit is contained in:
Reino Muhl 2024-03-18 17:54:09 -04:00
Родитель 8fbc5139e2
Коммит 695d791b15
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
21 изменённых файлов: 320 добавлений и 42 удалений

Просмотреть файл

@ -4,11 +4,11 @@
import { NextResponse } from 'next/server';
import { app } from '../_nestapp/app';
import { app } from '@fxa/payments/ui/server';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
await app.getApp();
await app.initialize();
return NextResponse.json({});
}

Просмотреть файл

@ -1,6 +1,6 @@
import { PurchaseDetails, TermsAndPrivacy } from '@fxa/payments/ui/server';
import { getCartData, getContentfulContent } from '../../../_lib/apiClient';
import { app } from '../../../_nestapp/app';
import { app } from '@fxa/payments/ui/server';
import { headers } from 'next/headers';
import { getLocaleFromRequest } from '@fxa/shared/l10n';
import { CheckoutSearchParams } from '../layout';
@ -13,9 +13,6 @@ interface CheckoutParams {
cartId: string;
}
// Temporary code for demo purposes only - Replaced as part of FXA-8822
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
export default async function Checkout({
params,
searchParams,
@ -26,8 +23,7 @@ export default async function Checkout({
const headersList = headers();
const locale = getLocaleFromRequest(
searchParams,
headersList.get('accept-language'),
demoSupportedLanguages
headersList.get('accept-language')
);
const contentfulData = getContentfulContent(params.offeringId, locale);

Просмотреть файл

@ -1,28 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 'server-only';
import { CartService } from '@fxa/payments/cart';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
class AppSingleton {
private app!: Awaited<
ReturnType<typeof NestFactory.createApplicationContext>
>;
async getApp() {
if (this.app) return this.app;
this.app = await NestFactory.createApplicationContext(AppModule);
return this.app;
}
async getCartService() {
return (await this.getApp()).get(CartService);
}
}
export const app = new AppSingleton();

Просмотреть файл

@ -8,7 +8,6 @@ export const {
signOut,
} = NextAuth({
...authConfig,
debug: true,
providers: [
{
id: 'fxa',

Просмотреть файл

@ -9,6 +9,7 @@ import { AccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
import { Module } from '@nestjs/common';
import { RootConfig } from './config';
import { LocalizerServerFactory } from '@fxa/shared/l10n/server';
@Module({
imports: [
@ -27,6 +28,11 @@ import { RootConfig } from './config';
}),
],
controllers: [],
providers: [AccountDatabaseNestFactory, CartService, CartManager],
providers: [
AccountDatabaseNestFactory,
CartService,
CartManager,
LocalizerServerFactory,
],
})
export class AppModule {}

Просмотреть файл

@ -0,0 +1,38 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 'server-only';
import { CartService } from '@fxa/payments/cart';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { LocalizerServer } from '@fxa/shared/l10n/server';
import { singleton } from '../utils/singleton';
class AppSingleton {
private app!: Awaited<
ReturnType<typeof NestFactory.createApplicationContext>
>;
async initialize() {
if (!this.app) {
this.app = await NestFactory.createApplicationContext(AppModule);
}
}
async getLocalizerServer() {
// Temporary until Next.js canary lands
await this.initialize();
return this.app.get(LocalizerServer);
}
async getCartService() {
// Temporary until Next.js canary lands
await this.initialize();
return this.app.get(CartService);
}
}
export const app = singleton('nestApp', new AppSingleton()) as AppSingleton;

Просмотреть файл

@ -1,6 +1,5 @@
import { Invoice } from '@fxa/payments/cart';
import {
getBundle,
getFormattedMsg,
getLocalizedCurrency,
getLocalizedCurrencyString,
@ -9,6 +8,7 @@ import { FluentBundle } from '@fluent/bundle';
import Image from 'next/image';
import { formatPlanPricing } from '../utils/helpers';
import '../../styles/index.css';
import { app } from '@fxa/payments/ui/server';
type ListLabelItemProps = {
labelLocalizationId: string;
@ -80,7 +80,9 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
// and then that instance is used for all requests.
// Approach 1 (Experimental): https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
// Approach 2 (Node global): https://github.com/vercel/next.js/blob/canary/examples/with-knex/knex/index.js#L13
const l10n = await getBundle([props.locale]);
//const l10n = await getBundle([props.locale]);
const localizer = await app.getLocalizerServer();
const l10n = localizer.getBundle(props.locale);
return (
<div className="component-card text-sm px-4 rounded-t-none tablet:rounded-t-lg">

Просмотреть файл

@ -0,0 +1,13 @@
/***
* ISC License
Copyright 2022 Jon Jensen
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
export function singleton<T>(name: string, value: T) {
const yolo = global as any;
yolo.__singletons ??= {};
yolo.__singletons[name] ??= value;
return yolo.__singletons[name];
}

Просмотреть файл

@ -1,3 +1,4 @@
// Use this file to export React server components
export * from './lib/server/purchase-details';
export * from './lib/server/terms-and-privacy';
export * from './lib/nestapp/app';

Просмотреть файл

@ -11,3 +11,4 @@ export * from './lib/localize-timestamp';
export * from './lib/other-languages';
export * from './lib/parse-accept-language';
export { default as supportedLanguages } from './lib/supported-languages.json';
export * from './lib/localizer/localizer.base';

Просмотреть файл

@ -0,0 +1,68 @@
import { FluentBundle, FluentResource } from '@fluent/bundle';
import type { ILocalizerBindings } from './localizer.interfaces';
export class LocalizerBase {
protected readonly bindings: ILocalizerBindings;
constructor(bindings: ILocalizerBindings) {
this.bindings = bindings;
}
protected async fetchMessages(currentLocales: string[]) {
const fetchedPending: Record<string, Promise<string>> = {};
const fetched: Record<string, string> = {};
for (const locale of currentLocales) {
fetchedPending[locale] = this.fetchTranslatedMessages(locale);
}
// All we're doing here is taking `{ localeName: pendingLocaleMessagesPromise }` objects and
// parallelizing the promise resolutions instead of waiting for them to finish syncronously. We
// then return the result in the same `{ localeName: messages }` format for fulfilled promises.
const fetchedLocales = await Promise.allSettled(
Object.keys(fetchedPending).map(async (locale) => ({
locale,
fetchedLocale: await fetchedPending[locale],
}))
);
fetchedLocales.forEach((fetchedLocale) => {
if (fetchedLocale.status === 'fulfilled') {
fetched[fetchedLocale.value.locale] = fetchedLocale.value.fetchedLocale;
}
if (fetchedLocale.status === 'rejected') {
console.error(
'Could not fetch locale with reason: ',
fetchedLocale.reason
);
}
});
return fetched;
}
protected createBundleGenerator(fetched: Record<string, string>) {
async function* generateBundles(currentLocales: string[]) {
for (const locale of currentLocales) {
const source = fetched[locale];
if (source) {
const bundle = new FluentBundle(locale, {
useIsolating: false,
});
const resource = new FluentResource(source);
bundle.addResource(resource);
yield bundle;
}
}
}
return generateBundles;
}
/**
* Returns the set of translated strings for the specified locale.
* @param locale Locale to use, defaults to en.
*/
protected async fetchTranslatedMessages(locale = 'en') {
const mainFtlPath = `${this.bindings.opts.translations.basePath}/${locale}/main.ftl`;
return this.bindings.fetchResource(mainFtlPath);
}
}

Просмотреть файл

@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { LocalizerOpts } from './localizer.models';
export interface ILocalizerBindings {
opts: LocalizerOpts;
fetchResource(path: string): Promise<string>;
}

Просмотреть файл

@ -0,0 +1,11 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type TranslationOpts = {
basePath: string;
};
export type LocalizerOpts = {
translations: TranslationOpts;
};

Просмотреть файл

@ -0,0 +1,18 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Provider } from '@nestjs/common';
import { LocalizerBindingsServer, LocalizerServer } from './localizer.server';
export const LocalizerServerFactory: Provider<LocalizerServer> = {
provide: LocalizerServer,
useFactory: async () => {
const bindings = new LocalizerBindingsServer({
translations: { basePath: './public/locales' },
});
const localizer = new LocalizerServer(bindings);
await localizer.populateBundles();
return localizer;
},
};

Просмотреть файл

@ -0,0 +1,69 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LocalizerServer } from './localizer.server';
import { ILocalizerBindings } from './localizer.interfaces';
import supportedLanguages from '../supported-languages.json';
describe('LocalizerServer', () => {
let localizer: LocalizerServer;
const bindings: ILocalizerBindings = {
opts: {
translations: {
basePath: '',
},
},
fetchResource: async (filePath) => {
const locale = filePath.split('/')[1];
switch (locale) {
case 'fr':
return 'test-id = Test Fr';
default:
return 'test-id = Test';
}
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: LocalizerServer,
useFactory: async () => new LocalizerServer(bindings),
},
],
}).compile();
localizer = module.get(LocalizerServer);
await localizer.populateBundles();
});
it('should be defined', () => {
expect(localizer).toBeDefined();
expect(localizer).toBeInstanceOf(LocalizerServer);
});
it('testing', async () => {
const bundle = localizer.getBundle('fr');
expect(bundle.locales).toEqual(['fr']);
expect(bundle.getMessage('test-id')?.value).toBe('Test Fr');
});
describe('populateBundles', () => {
it('should succeed', async () => {
expect(localizer['bundles'].size).toBe(supportedLanguages.length);
});
});
describe('getBundle', () => {
it('should return bundle for locale', () => {
const bundle = localizer.getBundle('fr');
expect(bundle.locales).toEqual(['fr']);
expect(bundle.getMessage('test-id')?.value).toBe('Test Fr');
});
it('should return empty bundle for missing locale', () => {
const bundle = localizer.getBundle('nope');
expect(bundle.locales).toEqual(['nope']);
expect(bundle._messages.size).toBe(0);
});
});
});

Просмотреть файл

@ -0,0 +1,65 @@
import { FluentBundle } from '@fluent/bundle';
import { LocalizerBase } from './localizer.base';
import { Injectable } from '@nestjs/common';
import supportedLanguages from '../supported-languages.json';
import type { ILocalizerBindings } from './localizer.interfaces';
import { promises as fsPromises, existsSync } from 'fs';
import { join } from 'path';
import type { LocalizerOpts } from './localizer.models';
export class LocalizerBindingsServer implements ILocalizerBindings {
readonly opts: LocalizerOpts;
constructor(opts?: LocalizerOpts) {
this.opts = Object.assign(
{
translations: {
basePath: join(__dirname, '../../public/locales'),
},
},
opts
);
// Make sure config is legit
this.validateConfig();
}
protected async validateConfig() {
if (!existsSync(this.opts.translations.basePath)) {
throw new Error('Invalid ftl translations basePath');
}
}
async fetchResource(path: string): Promise<string> {
return fsPromises.readFile(path, {
encoding: 'utf8',
});
}
}
@Injectable()
export class LocalizerServer extends LocalizerBase {
private readonly bundles: Map<string, FluentBundle> = new Map();
constructor(bindings: ILocalizerBindings) {
super(bindings);
}
async populateBundles() {
const fetchedMessages = await this.fetchMessages(supportedLanguages);
const bundleGenerator = this.createBundleGenerator(fetchedMessages);
for await (const bundle of bundleGenerator(supportedLanguages)) {
this.bundles.set(bundle.locales[0], bundle);
}
}
getBundle(locale: string) {
const bundle = this.bundles.get(locale);
if (!bundle) {
// If no bundle is found, return an empty bundle
return new FluentBundle(locale, {
useIsolating: false,
});
}
return bundle;
}
}

Просмотреть файл

@ -0,0 +1,2 @@
export * from './lib/localizer/localizer.server';
export * from './lib/localizer/localizer.provider';

Просмотреть файл

@ -63,7 +63,10 @@ const permitAdditionalJSImports = (config) => {
);
}
config.resolve.fallback = { fs: false, path: false };
config.resolve.fallback = {
fs: false,
path: false,
};
return config;
};

Просмотреть файл

@ -350,7 +350,10 @@ module.exports = function (webpackEnv) {
]
),
],
fallback: { fs: false, path: false },
fallback: {
fs: false,
path: false,
},
},
module: {
strictExportPresence: true,

Просмотреть файл

@ -48,6 +48,7 @@
],
"@fxa/shared/error": ["libs/shared/error/src/index.ts"],
"@fxa/shared/l10n": ["libs/shared/l10n/src/index.ts"],
"@fxa/shared/l10n/server": ["libs/shared/l10n/src/server.ts"],
"@fxa/shared/log": ["libs/shared/log/src/index.ts"],
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"],
},