Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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