From 2acf95a94993e51876d4004d2c3bc0a04be0a419 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 10 Apr 2024 14:44:49 +0000 Subject: [PATCH] fix(@angular-devkit/build-angular): do not generate an `index.html` file in the browser directory when using SSR. BREAKING CHANGE: By default, the index.html file is no longer emitted in the browser directory when using the application builder with SSR. Instead, an index.csr.html file is emitted. This change is implemented because in many cases server and cloud providers incorrectly treat the index.html file as a statically generated page. If you still require the old behavior, you can use the `index` option to specify the `output` file name. ```json "architect": { "build": { "builder": "@angular-devkit/build-angular:application", "options": { "outputPath": "dist/my-app", "index": { "input": "src/index.html", "output": "index.html" } } } } ``` --- .../src/builders/application/options.ts | 84 +++++++++++-------- .../behavior/index-preload-hints_spec.ts | 6 +- .../tests/behavior/rebuild-errors_spec.ts | 2 +- .../application/tests/options/index_spec.ts | 22 +++++ .../application-builder/server.ts.template | 7 +- 5 files changed, 80 insertions(+), 41 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index bdcf37c95ad2..8b7a3a64d30a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -194,35 +194,6 @@ export async function normalizeOptions( ? undefined : await getTailwindConfig(searchDirectories, workspaceRoot, context); - const globalStyles = normalizeGlobalEntries(options.styles, 'styles'); - const globalScripts = normalizeGlobalEntries(options.scripts, 'scripts'); - - let indexHtmlOptions; - // index can never have a value of `true` but in the schema it's of type `boolean`. - if (typeof options.index !== 'boolean') { - indexHtmlOptions = { - input: path.join( - workspaceRoot, - typeof options.index === 'string' ? options.index : options.index.input, - ), - // The output file will be created within the configured output path - output: - typeof options.index === 'string' - ? path.basename(options.index) - : options.index.output || 'index.html', - insertionOrder: [ - ['polyfills', true], - ...globalStyles.filter((s) => s.initial).map((s) => [s.name, false]), - ...globalScripts.filter((s) => s.initial).map((s) => [s.name, false]), - ['main', true], - // [name, esm] - ] as [string, boolean][], - transformer: extensions?.indexHtmlTransformer, - // Preload initial defaults to true - preloadInitial: typeof options.index !== 'object' || (options.index.preloadInitial ?? true), - }; - } - let serverEntryPoint: string | undefined; if (options.server) { serverEntryPoint = path.join(workspaceRoot, options.server); @@ -259,10 +230,57 @@ export async function normalizeOptions( }; } - if ((appShellOptions || ssrOptions || prerenderOptions) && !serverEntryPoint) { - throw new Error( - 'The "server" option is required when enabling "ssr", "prerender" or "app-shell".', - ); + const globalStyles = normalizeGlobalEntries(options.styles, 'styles'); + const globalScripts = normalizeGlobalEntries(options.scripts, 'scripts'); + let indexHtmlOptions; + // index can never have a value of `true` but in the schema it's of type `boolean`. + if (typeof options.index !== 'boolean') { + let indexOutput: string; + // The output file will be created within the configured output path + if (typeof options.index === 'string') { + /** + * If SSR is activated, create a distinct entry file for the `index.html`. + * This is necessary because numerous server/cloud providers automatically serve the `index.html` as a static file + * if it exists (handling SSG). + * For instance, accessing `foo.com/` would lead to `foo.com/index.html` being served instead of hitting the server. + */ + const indexBaseName = path.basename(options.index); + indexOutput = ssrOptions && indexBaseName === 'index.html' ? 'index.csr.html' : indexBaseName; + } else { + indexOutput = options.index.output || 'index.html'; + } + + indexHtmlOptions = { + input: path.join( + workspaceRoot, + typeof options.index === 'string' ? options.index : options.index.input, + ), + output: indexOutput, + insertionOrder: [ + ['polyfills', true], + ...globalStyles.filter((s) => s.initial).map((s) => [s.name, false]), + ...globalScripts.filter((s) => s.initial).map((s) => [s.name, false]), + ['main', true], + // [name, esm] + ] as [string, boolean][], + transformer: extensions?.indexHtmlTransformer, + // Preload initial defaults to true + preloadInitial: typeof options.index !== 'object' || (options.index.preloadInitial ?? true), + }; + } + + if (appShellOptions || ssrOptions || prerenderOptions) { + if (!serverEntryPoint) { + throw new Error( + 'The "server" option is required when enabling "ssr", "prerender" or "app-shell".', + ); + } + + if (!indexHtmlOptions) { + throw new Error( + 'The "index" option cannot be set to false when enabling "ssr", "prerender" or "app-shell".', + ); + } } // Initial options to keep diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/index-preload-hints_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/index-preload-hints_spec.ts index e8fb7137d2b0..312619f30fd5 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/index-preload-hints_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/index-preload-hints_spec.ts @@ -38,13 +38,11 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { await harness.modifyFile('src/tsconfig.app.json', (content) => { const tsConfig = JSON.parse(content); tsConfig.files ??= []; - tsConfig.files.push('main.server.ts', 'server.ts'); + tsConfig.files.push('main.server.ts'); return JSON.stringify(tsConfig); }); - await harness.writeFile('src/server.ts', `console.log('Hello!');`); - harness.useTarget('build', { ...BASE_OPTIONS, server: 'src/main.server.ts', @@ -57,7 +55,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/server/main.server.mjs').toExist(); harness - .expectFile('dist/browser/index.html') + .expectFile('dist/browser/index.csr.html') .content.not.toMatch(//); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts index 416f3d3fb5c9..0fcf1e590056 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -315,7 +315,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }); const buildCount = await harness - .execute({ outputLogsOnFailure: true }) + .execute({ outputLogsOnFailure: false }) .pipe( timeout(BUILD_TIMEOUT), concatMap(async ({ result, logs }, index) => { diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts index 5b6fac44a471..a11f69200b67 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts @@ -205,5 +205,27 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/index.html').content.not.toContain('modulepreload'); harness.expectFile('dist/browser/index.html').content.not.toContain('chunk-'); }); + + it(`should generate 'index.csr.html' instead of 'index.html' by default when ssr is enabled.`, async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); }); }); diff --git a/packages/schematics/angular/ssr/files/application-builder/server.ts.template b/packages/schematics/angular/ssr/files/application-builder/server.ts.template index 7bf10181c7d1..399fba5148b3 100644 --- a/packages/schematics/angular/ssr/files/application-builder/server.ts.template +++ b/packages/schematics/angular/ssr/files/application-builder/server.ts.template @@ -20,12 +20,13 @@ export function app(): express.Express { // Example Express Rest API endpoints // server.get('/api/**', (req, res) => { }); // Serve static files from /<%= browserDistDirectory %> - server.get('*.*', express.static(browserDistFolder, { - maxAge: '1y' + server.get('**', express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html', })); // All regular routes use the Angular engine - server.get('*', (req, res, next) => { + server.get('**', (req, res, next) => { const { protocol, originalUrl, baseUrl, headers } = req; commonEngine