Skip to content

Commit ea61a35

Browse files
authored
Locale routing (#331)
* Locale routing * Allow stripping locales before routing * Bump nextjs-demo to version with locales * Move router-lib impl out of index.ts * Split the monolith file apart * Fix normalizedPathPrefix trailing `/` stripping * Add unit tests for load-app-frame * Create redirect-default-file.spec.ts * RouteApp unit tests * Create get-app-info.spec.ts * Fix _next/data routing with locales * Enable locales * Fix yaml * Fix test path for locale and no basePath * Update nextjs-demo.spec.ts * Fix lint * Fix test
1 parent 58332bd commit ea61a35

28 files changed

+1376
-841
lines changed

packages/cdk/lib/MicroApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export class MicroAppsStack extends Stack {
248248
table: table.table,
249249
tableNameForEdgeToOrigin: tableName ? tableName : `${assetNameRoot}${assetNameSuffix}`,
250250
allowedFunctionUrlAccounts,
251+
allowedLocalePrefixes: ['en', 'sv'],
251252
...optionalAssetNameOpts,
252253
...optionals3PolicyOpts,
253254
...optionalCustomDomainOpts,

packages/cdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"cdk": "cdk"
1515
},
1616
"dependencies": {
17-
"@pwrdrvr/microapps-app-nextjs-demo-cdk": "0.6.3",
17+
"@pwrdrvr/microapps-app-nextjs-demo-cdk": "0.7.0",
1818
"@pwrdrvr/microapps-app-release-cdk": "0.5.3",
1919
"source-map-support": "0.5.21",
2020
"aws-cdk-lib": "2.24.1",

packages/microapps-cdk/src/MicroApps.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ export interface MicroAppsProps {
302302
* @default []
303303
*/
304304
readonly allowedFunctionUrlAccounts?: string[];
305+
306+
/**
307+
* List of allowed locale prefixes for pages
308+
*
309+
* @example: ['en', 'fr', 'es']
310+
* @default none
311+
*/
312+
readonly allowedLocalePrefixes?: string[];
305313
}
306314

307315
/**
@@ -459,6 +467,7 @@ export class MicroApps extends Construct implements IMicroApps {
459467
rootPathPrefix,
460468
tableRulesArn: tableNameForEdgeToOrigin || this._svcs.table.tableName,
461469
allowedFunctionUrlAccounts,
470+
allowedLocalePrefixes: props.allowedLocalePrefixes,
462471
});
463472

464473
edgeLambdas.push(...this._edgeToOrigin.edgeToOriginLambdas);

packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export interface MicroAppsEdgeToOriginProps {
7878
*/
7979
readonly rootPathPrefix?: string;
8080

81+
/**
82+
* List of allowed locale prefixes for pages
83+
*
84+
* @example: ['en', 'fr', 'es']
85+
* @default none
86+
*/
87+
readonly allowedLocalePrefixes?: string[];
88+
8189
/**
8290
* Adds an X-Forwarded-Host-Header when calling API Gateway
8391
*
@@ -157,6 +165,7 @@ export interface GenerateEdgeToOriginConfigOptions {
157165
readonly replaceHostHeader: boolean;
158166
readonly tableName?: string;
159167
readonly rootPathPrefix?: string;
168+
readonly locales?: string[];
160169
}
161170

162171
interface IMicroAppsEdgeToOriginRoleStackProps extends StackProps {
@@ -283,7 +292,12 @@ ${props.signingMode === '' ? '' : `signingMode: ${props.signingMode}`}
283292
addXForwardedHostHeader: ${props.addXForwardedHostHeader}
284293
replaceHostHeader: ${props.replaceHostHeader}
285294
${props.tableName ? `tableName: '${props.tableName}'` : ''}
286-
${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`;
295+
${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}
296+
${
297+
props.locales && props.locales.length > 0
298+
? `locales: [${props.locales.map((locale) => `'${locale}'`).join(', ')}]`
299+
: ''
300+
}`;
287301
}
288302

289303
private _edgeToOriginFunction: lambda.Function | cf.experimental.EdgeFunction;
@@ -329,6 +343,7 @@ ${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`;
329343
replaceHostHeader,
330344
signingMode: signingMode === 'none' ? '' : signingMode,
331345
rootPathPrefix,
346+
locales: props.allowedLocalePrefixes,
332347
...(tableRulesArn
333348
? {
334349
tableName: tableRulesArn,

packages/microapps-edge-to-origin/src/config/config.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('config', () => {
1818
signingMode: 'sign',
1919
tableName: 'microapps',
2020
rootPathPrefix: '',
21+
locales: [],
2122
};
2223
const loader = new TSConvict<Config>(Config);
2324
const config = loader.load(rawConfig);

packages/microapps-edge-to-origin/src/config/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export interface IConfig {
4848
* Path prefix on the root of the MicroApps deployment
4949
*/
5050
readonly rootPathPrefix: string;
51+
52+
/**
53+
* List of allowed locale prefixes for pages
54+
* If there is a match then the 1st path after the `rootPathPrefix` is
55+
* removed from the path before looking up the AppName and Version.
56+
*
57+
* @example ['en', 'fr', 'es']
58+
* @default none
59+
*/
60+
readonly locales: string[];
5161
}
5262

5363
/**
@@ -171,4 +181,11 @@ export class Config implements IConfig {
171181
env: 'ROOT_PATH_PREFIX',
172182
})
173183
public rootPathPrefix!: string;
184+
185+
@convict.Property({
186+
doc: 'Allowed locales for pages',
187+
default: [],
188+
env: 'LOCALES',
189+
})
190+
public locales!: string[];
174191
}

packages/microapps-edge-to-origin/src/index.route.prefix.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const theConfig: Writeable<IConfig> = {
1717
signingMode: 'sign',
1818
tableName: '',
1919
rootPathPrefix: '/prefix',
20+
locales: [],
2021
};
2122
const origConfig = { ...theConfig };
2223
Object.defineProperty(Config, 'instance', {

packages/microapps-edge-to-origin/src/index.route.root.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const theConfig: Writeable<IConfig> = {
1515
signingMode: 'sign',
1616
tableName: '',
1717
rootPathPrefix: '',
18+
locales: [],
1819
};
1920

2021
let dynamoClient: dynamodb.DynamoDBClient;

packages/microapps-edge-to-origin/src/index.route.spec.ts

Lines changed: 142 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const theConfig: Writeable<IConfig> = {
1616
signingMode: 'sign',
1717
tableName: '',
1818
rootPathPrefix: '',
19+
locales: [],
1920
};
2021
const origConfig = { ...theConfig };
2122
Object.defineProperty(Config, 'instance', {
@@ -291,34 +292,151 @@ describe('edge-to-origin - routing - without prefix', () => {
291292
expect(requestResponse?.origin?.custom?.domainName).toBe('abc123.lambda-url.us-east-1.on.aws');
292293
});
293294

295+
it('should route `direct` app request with *locale* to origin for appName', async () => {
296+
theConfig.replaceHostHeader = true;
297+
theConfig.locales = ['en', 'sv'];
298+
299+
const AppName = 'BatDirect';
300+
const SemVer = '1.2.1-beta.1';
301+
const Locale = 'sv';
302+
303+
const app = new Application({
304+
AppName,
305+
DisplayName: 'Direct Bat App',
306+
});
307+
await app.Save(dbManager);
308+
309+
const version = new Version({
310+
AppName,
311+
SemVer,
312+
Status: 'deployed',
313+
Type: 'lambda-url',
314+
StartupType: 'direct',
315+
URL: 'https://abc123.lambda-url.us-east-1.on.aws/',
316+
});
317+
await version.Save(dbManager);
318+
319+
const rules = new Rules({
320+
AppName,
321+
Version: 0,
322+
RuleSet: { default: { SemVer, AttributeName: '', AttributeValue: '' } },
323+
});
324+
await rules.Save(dbManager);
325+
326+
// Call the handler
327+
// @ts-expect-error no callback
328+
const response = await handler(
329+
{
330+
Records: [
331+
{
332+
cf: {
333+
config: {
334+
distributionDomainName: 'zyz.cloudfront.net',
335+
distributionId: '123',
336+
eventType: 'origin-request',
337+
requestId: '123',
338+
},
339+
request: {
340+
headers: {
341+
host: [
342+
{
343+
key: 'Host',
344+
value: 'zyz.cloudfront.net',
345+
},
346+
],
347+
},
348+
method: 'GET',
349+
querystring: '',
350+
clientIp: '1.1.1.1',
351+
uri: `/${Locale}/${AppName.toLowerCase()}`,
352+
origin: {
353+
custom: {
354+
customHeaders: {},
355+
domainName: 'zyz.cloudfront.net',
356+
keepaliveTimeout: 5,
357+
path: '',
358+
port: 443,
359+
protocol: 'https',
360+
readTimeout: 30,
361+
sslProtocols: ['TLSv1.2'],
362+
},
363+
},
364+
},
365+
},
366+
},
367+
],
368+
} as lambda.CloudFrontRequestEvent,
369+
{} as lambda.Context,
370+
);
371+
372+
const requestResponse = response as lambda.CloudFrontRequest;
373+
expect(requestResponse).toBeDefined();
374+
expect(requestResponse).not.toHaveProperty('status');
375+
expect(requestResponse).not.toHaveProperty('body');
376+
expect(requestResponse).toHaveProperty('headers');
377+
expect(requestResponse.headers['x-microapps-appname'][0].key).toBe('X-MicroApps-AppName');
378+
expect(requestResponse.headers['x-microapps-appname'][0].value).toBe(AppName.toLowerCase());
379+
expect(requestResponse.headers).toHaveProperty('x-microapps-semver');
380+
expect(requestResponse.headers['x-microapps-semver'][0].key).toBe('X-MicroApps-SemVer');
381+
expect(requestResponse.headers['x-microapps-semver'][0].value).toBe(SemVer);
382+
expect(requestResponse.headers).toHaveProperty('host');
383+
expect(requestResponse.headers.host).toHaveLength(1);
384+
expect(requestResponse.headers.host[0].key).toBe('Host');
385+
expect(requestResponse.headers.host[0].value).toBe('abc123.lambda-url.us-east-1.on.aws');
386+
expect(requestResponse).toHaveProperty('origin');
387+
expect(requestResponse.origin).toHaveProperty('custom');
388+
expect(requestResponse?.origin?.custom).toHaveProperty('domainName');
389+
expect(requestResponse?.origin?.custom?.domainName).toBe('abc123.lambda-url.us-east-1.on.aws');
390+
});
391+
294392
describe('/_next/data/ requests with no basePath', () => {
295393
const testCases = [
296-
[
297-
{
298-
AppName: 'BatDirectNoBaseNextData',
299-
LambdaURL1: 'https://abc123.lambda-url.us-east-1.on.aws/',
300-
SemVer1: '1.2.1-beta.1',
301-
LambdaURL2: 'https://abc1234567.lambda-url.us-east-1.on.aws/',
302-
SemVer2: '1.2.1-beta.2',
303-
SuffixPath: 'batdirectnobasenextdata/route.json',
304-
},
305-
],
306-
[
307-
{
308-
AppName: 'BatDirectNoBaseNextDataRootRoute',
309-
LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
310-
SemVer1: '1.2.1-beta.3',
311-
LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
312-
SemVer2: '1.2.1-beta.4',
313-
SuffixPath: 'batdirectnobasenextdatarootroute.json',
314-
},
315-
],
394+
{
395+
AppName: 'BatDirectNoBaseNextData',
396+
LambdaURL1: 'https://abc123.lambda-url.us-east-1.on.aws/',
397+
SemVer1: '1.2.1-beta.1',
398+
LambdaURL2: 'https://abc1234567.lambda-url.us-east-1.on.aws/',
399+
SemVer2: '1.2.1-beta.2',
400+
Locales: [],
401+
RawPath: '/_next/data/1.2.1-beta.2/batdirectnobasenextdata/route.json',
402+
SuffixPath: 'batdirectnobasenextdata/route.json',
403+
},
404+
{
405+
AppName: 'BatDirectNoBaseNextDataRootRoute',
406+
LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
407+
SemVer1: '1.2.1-beta.3',
408+
LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
409+
SemVer2: '1.2.1-beta.4',
410+
Locales: [],
411+
RawPath: '/_next/data/1.2.1-beta.4/batdirectnobasenextdatarootroute.json',
412+
SuffixPath: 'batdirectnobasenextdatarootroute.json',
413+
},
414+
{
415+
AppName: 'BatDirectNoBaseNextDataRootRouteLocale',
416+
LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
417+
SemVer1: '1.2.1-beta.3',
418+
LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
419+
SemVer2: '1.2.1-beta.4',
420+
Locales: ['en', 'sv'],
421+
RawPath: '/_next/data/1.2.1-beta.4/sv/batdirectnobasenextdatarootroutelocale.json',
422+
SuffixPath: 'batdirectnobasenextdatarootroutelocale.json',
423+
},
316424
];
317425

318426
it.each(testCases)(
319-
'should route `direct` /_next/data/[${SemVer2}]/[${AppName}] request to [${AppName}] when it exists but ${SemVer1} is the default',
320-
async ({ AppName, LambdaURL1, SemVer1, LambdaURL2, SemVer2, SuffixPath }) => {
427+
'should route `direct` /_next/data/[$SemVer2]/[$AppName] request to [$AppName] when it exists but $SemVer1 is the default',
428+
async ({
429+
AppName,
430+
LambdaURL1,
431+
SemVer1,
432+
LambdaURL2,
433+
SemVer2,
434+
RawPath,
435+
Locales,
436+
SuffixPath,
437+
}) => {
321438
theConfig.replaceHostHeader = true;
439+
theConfig.locales = Locales;
322440

323441
const app = new Application({
324442
AppName,
@@ -379,7 +497,7 @@ describe('edge-to-origin - routing - without prefix', () => {
379497
method: 'GET',
380498
querystring: '',
381499
clientIp: '1.1.1.1',
382-
uri: `/_next/data/${SemVer2}/${SuffixPath}`,
500+
uri: `${RawPath}`,
383501
origin: {
384502
custom: {
385503
customHeaders: {},
@@ -414,7 +532,7 @@ describe('edge-to-origin - routing - without prefix', () => {
414532
expect(requestResponse.headers).toHaveProperty('x-microapps-semver');
415533
expect(requestResponse.headers['x-microapps-semver'][0].key).toBe('X-MicroApps-SemVer');
416534
expect(requestResponse.headers['x-microapps-semver'][0].value).toBe(SemVer2);
417-
expect(requestResponse.uri).toBe(`/_next/data/${SemVer2}/${SuffixPath}`);
535+
expect(requestResponse.uri).toBe(`${RawPath}`);
418536
expect(requestResponse).toHaveProperty('origin');
419537
expect(requestResponse.origin).toHaveProperty('custom');
420538
expect(requestResponse?.origin?.custom).toHaveProperty('domainName');

0 commit comments

Comments
 (0)