Skip to content

Commit

Permalink
fix(@angular/ssr): correctly handle serving of prerendered i18n pages
Browse files Browse the repository at this point in the history
Ensures proper handling of internationalized (i18n) pages during the  serving of prerendered content.

(cherry picked from commit e4448bb)
  • Loading branch information
alan-agius4 committed Dec 2, 2024
1 parent c5a83cc commit 5880a02
Show file tree
Hide file tree
Showing 9 changed files with 107 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export async function executePostBundleSteps(
const {
baseHref = '/',
serviceWorker,
i18nOptions,
indexHtmlOptions,
optimizationOptions,
sourcemapOptions,
Expand Down Expand Up @@ -114,6 +115,7 @@ export async function executePostBundleSteps(
optimizationOptions.styles.inlineCritical ?? false,
undefined,
locale,
baseHref,
);

additionalOutputFiles.push(
Expand Down Expand Up @@ -194,6 +196,7 @@ export async function executePostBundleSteps(
optimizationOptions.styles.inlineCritical ?? false,
serializableRouteTreeNodeForManifest,
locale,
baseHref,
);

for (const chunk of serverAssetsChunks) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export default {
* server-side rendering and routing.
* @param locale - An optional string representing the locale or language code to be used for
* the application, helping with localization and rendering content specific to the locale.
* @param baseHref - The base HREF for the application. This is used to set the base URL
* for all relative URLs in the application.
*
* @returns An object containing:
* - `manifestContent`: A string of the SSR manifest content.
Expand All @@ -114,6 +116,7 @@ export function generateAngularServerAppManifest(
inlineCriticalCss: boolean,
routes: readonly unknown[] | undefined,
locale: string | undefined,
baseHref: string,
): {
manifestContent: string;
serverAssetsChunks: BuildOutputFile[];
Expand Down Expand Up @@ -142,9 +145,10 @@ export function generateAngularServerAppManifest(
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
inlineCriticalCss: ${inlineCriticalCss},
baseHref: '${baseHref}',
locale: ${locale !== undefined ? `'${locale}'` : undefined},
routes: ${JSON.stringify(routes, undefined, 2)},
assets: new Map([\n${serverAssetsContent.join(', \n')}\n]),
locale: ${locale !== undefined ? `'${locale}'` : undefined},
};
`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export async function prerenderPages(
workspaceRoot,
outputFilesForWorker,
assetsReversed,
appShellOptions,
outputMode,
appShellRoute ?? appShellOptions?.route,
);
Expand All @@ -188,7 +187,6 @@ async function renderPages(
workspaceRoot: string,
outputFilesForWorker: Record<string, string>,
assetFilesForWorker: Record<string, string>,
appShellOptions: AppShellOptions | undefined,
outputMode: OutputMode | undefined,
appShellRoute: string | undefined,
): Promise<{
Expand Down Expand Up @@ -224,7 +222,7 @@ async function renderPages(
for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
// Remove the base href from the file output path.
const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash)
? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length - 1))
? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length))
: route;

const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html');
Expand Down
30 changes: 28 additions & 2 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,7 @@ export class AngularServerApp {
return null;
}

const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
const assetPath = stripLeadingSlash(joinUrlParts(pathname, 'index.html'));
const assetPath = this.buildServerAssetPathFromRequest(request);
if (!this.assets.hasServerAsset(assetPath)) {
return null;
}
Expand Down Expand Up @@ -355,6 +354,33 @@ export class AngularServerApp {

return new Response(html, responseInit);
}

/**
* Constructs the asset path on the server based on the provided HTTP request.
*
* This method processes the incoming request URL to derive a path corresponding
* to the requested asset. It ensures the path points to the correct file (e.g.,
* `index.html`) and removes any base href if it is not part of the asset path.
*
* @param request - The incoming HTTP request object.
* @returns The server-relative asset path derived from the request.
*/
private buildServerAssetPathFromRequest(request: Request): string {
let { pathname: assetPath } = new URL(request.url);
if (!assetPath.endsWith('/index.html')) {
// Append "index.html" to build the default asset path.
assetPath = joinUrlParts(assetPath, 'index.html');
}

const { baseHref } = this.manifest;
// Check if the asset path starts with the base href and the base href is not (`/` or ``).
if (baseHref.length > 1 && assetPath.startsWith(baseHref)) {
// Remove the base href from the start of the asset path to align with server-asset expectations.
assetPath = assetPath.slice(baseHref.length);
}

return stripLeadingSlash(assetPath);
}
}

let angularServerApp: AngularServerApp | undefined;
Expand Down
6 changes: 6 additions & 0 deletions packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export interface AngularAppEngineManifest {
* Manifest for a specific Angular server application, defining assets and bootstrap logic.
*/
export interface AngularAppManifest {
/**
* The base href for the application.
* This is used to determine the root path of the application.
*/
readonly baseHref: string;

/**
* A map of assets required by the server application.
* Each entry in the map consists of:
Expand Down
63 changes: 52 additions & 11 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,44 @@ describe('AngularAppEngine', () => {
async () => {
@Component({
standalone: true,
selector: `app-home-${locale}`,
template: `Home works ${locale.toUpperCase()}`,
selector: `app-ssr-${locale}`,
template: `SSR works ${locale.toUpperCase()}`,
})
class HomeComponent {}
class SSRComponent {}

@Component({
standalone: true,
selector: `app-ssg-${locale}`,
template: `SSG works ${locale.toUpperCase()}`,
})
class SSGComponent {}

setAngularAppTestingManifest(
[{ path: 'home', component: HomeComponent }],
[{ path: '**', renderMode: RenderMode.Server }],
[
{ path: 'ssg', component: SSGComponent },
{ path: 'ssr', component: SSRComponent },
],
[
{ path: 'ssg', renderMode: RenderMode.Prerender },
{ path: '**', renderMode: RenderMode.Server },
],
'/' + locale,
{
'ssg/index.html': {
size: 25,
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
text: async () => `<html>
<head>
<title>SSG page</title>
<base href="/${locale}" />
</head>
<body>
SSG works ${locale.toUpperCase()}
</body>
</html>
`,
},
},
);

return {
Expand All @@ -58,29 +87,41 @@ describe('AngularAppEngine', () => {
appEngine = new AngularAppEngine();
});

describe('render', () => {
describe('handle', () => {
it('should return null for requests to unknown pages', async () => {
const request = new Request('https://example.com/unknown/page');
const response = await appEngine.handle(request);
expect(response).toBeNull();
});

it('should return null for requests with unknown locales', async () => {
const request = new Request('https://example.com/es/home');
const request = new Request('https://example.com/es/ssr');
const response = await appEngine.handle(request);
expect(response).toBeNull();
});

it('should return a rendered page with correct locale', async () => {
const request = new Request('https://example.com/it/home');
const request = new Request('https://example.com/it/ssr');
const response = await appEngine.handle(request);
expect(await response?.text()).toContain('Home works IT');
expect(await response?.text()).toContain('SSR works IT');
});

it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => {
const request = new Request('https://example.com/it/home/index.html');
const request = new Request('https://example.com/it/ssr/index.html');
const response = await appEngine.handle(request);
expect(await response?.text()).toContain('SSR works IT');
});

it('should return a serve prerendered page with correct locale', async () => {
const request = new Request('https://example.com/it/ssg');
const response = await appEngine.handle(request);
expect(await response?.text()).toContain('SSG works IT');
});

it('should correctly serve the prerendered content when the URL ends with "index.html" with correct locale', async () => {
const request = new Request('https://example.com/it/ssg/index.html');
const response = await appEngine.handle(request);
expect(await response?.text()).toContain('Home works IT');
expect(await response?.text()).toContain('SSG works IT');
});

it('should return null for requests to unknown pages in a locale', async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/angular/ssr/test/assets_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('ServerAsset', () => {

beforeAll(() => {
assetManager = new ServerAssets({
baseHref: '/',
bootstrap: undefined as never,
assets: new Map(
Object.entries({
Expand Down
1 change: 1 addition & 0 deletions packages/angular/ssr/test/testing-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function setAngularAppTestingManifest(
): void {
setAngularAppManifest({
inlineCriticalCss: false,
baseHref,
assets: new Map(
Object.entries({
...additionalServerAssets,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,18 @@ export default async function () {

// Tests responses
const port = await spawnServer();
const pathname = '/ssr';

const pathnamesToVerify = ['/ssr', '/ssg'];
for (const { lang } of langTranslations) {
const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`);
const text = await res.text();
for (const pathname of pathnamesToVerify) {
const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`);
const text = await res.text();

assert.match(
text,
new RegExp(`<p id="locale">${lang}</p>`),
`Response for '${lang}${pathname}': '<p id="locale">${lang}</p>' was not matched in content.`,
);
assert.match(
text,
new RegExp(`<p id="locale">${lang}</p>`),
`Response for '${lang}${pathname}': '<p id="locale">${lang}</p>' was not matched in content.`,
);
}
}
}

Expand Down

0 comments on commit 5880a02

Please sign in to comment.