From e6b377436a471073657dc35e7c7a28db6688760a Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 10 Jul 2023 16:14:06 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): add `ssr` option in application builder This commit adds an `ssr` option to the application builder, this can be either a `boolean` or an `object` with an `entryPoint` property. In the future, server bundles will only be emitted when the ssr option is truthy, as unlike SSR, SSG and AppShell do not require the server bundles to be written to disk. --- .../src/builders/application/execute-build.ts | 4 +- .../src/builders/application/options.ts | 12 ++++ .../src/builders/application/schema.json | 20 ++++++ .../application/tests/options/server_spec.ts | 10 +-- .../application/tests/options/ssr_spec.ts | 58 ++++++++++++++++ .../tools/esbuild/application-code-bundle.ts | 67 +++++++++++-------- .../src/utils/ssg/render-worker.ts | 2 +- tests/legacy-cli/e2e_runner.ts | 21 +++--- 8 files changed, 148 insertions(+), 46 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index 3f2429baa7..2890ff8384 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -52,6 +52,7 @@ export async function executeBuild( cacheOptions, prerenderOptions, appShellOptions, + ssrOptions, } = options; const browsers = getSupportedBrowsers(projectRoot, context.logger); @@ -167,8 +168,7 @@ export async function executeBuild( executionResult.addOutputFile(indexHtmlOptions.output, content); - if (serverEntryPoint) { - // TODO only add the below file when SSR is enabled. + if (ssrOptions) { executionResult.addOutputFile('index.server.html', contentWithoutCriticalCssInlined); } } 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 703f3bdeea..a462fc4e6f 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -186,6 +186,17 @@ export async function normalizeOptions( }; } + let ssrOptions; + if (options.ssr === true) { + ssrOptions = {}; + } else if (typeof options.ssr === 'object') { + const { entry } = options.ssr; + + ssrOptions = { + entry: entry && path.join(workspaceRoot, entry), + }; + } + let appShellOptions; if (options.appShell) { appShellOptions = { @@ -241,6 +252,7 @@ export async function normalizeOptions( serverEntryPoint, prerenderOptions, appShellOptions, + ssrOptions, verbose, watch, workspaceRoot, diff --git a/packages/angular_devkit/build_angular/src/builders/application/schema.json b/packages/angular_devkit/build_angular/src/builders/application/schema.json index f982a92784..01ac123add 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json @@ -452,6 +452,26 @@ } ] }, + "ssr": { + "description": "Server side render (SSR) pages of your application during runtime.", + "default": false, + "oneOf": [ + { + "type": "boolean", + "description": "Enable the server bundles to be written to disk." + }, + { + "type": "object", + "properties": { + "entry": { + "type": "string", + "description": "The server entry-point that when executed will spawn the web server." + } + }, + "additionalProperties": false + } + ] + }, "appShell": { "type": "boolean", "description": "Generates an application shell during build time.", diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts index 178be2e631..8f38a59668 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts @@ -30,7 +30,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - harness.expectFile('dist/server.mjs').toExist(); + harness.expectFile('dist/main.server.mjs').toExist(); harness.expectFile('dist/main.js').toExist(); }); @@ -45,7 +45,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - harness.expectFile('dist/server.mjs').toExist(); + harness.expectFile('dist/main.server.mjs').toExist(); }); it('fails and shows an error when file does not exist', async () => { @@ -62,7 +62,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { ); harness.expectFile('dist/main.js').toNotExist(); - harness.expectFile('dist/server.mjs').toNotExist(); + harness.expectFile('dist/main.server.mjs').toNotExist(); }); it('throws an error when given an empty string', async () => { @@ -88,8 +88,8 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - // Always uses the name `server.mjs` for the `server` option. - harness.expectFile('dist/server.mjs').toExist(); + // Always uses the name `main.server.mjs` for the `server` option. + harness.expectFile('dist/main.server.mjs').toExist(); }); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts new file mode 100644 index 0000000000..3de28bbe2f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('Option: "ssr"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/main.server.mjs').toExist(); + harness.expectFile('dist/server.mjs').toExist(); + }); + + it('resolves an absolute path as relative inside the workspace root', async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { + entry: '/file.mjs', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/server.mjs').toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index e0cfb94003..7dc249d733 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -97,7 +97,7 @@ export function createServerCodeBundleOptions( target: string[], sourceFileCache: SourceFileCache, ): BuildOptions { - const { jit, serverEntryPoint, workspaceRoot } = options; + const { jit, serverEntryPoint, workspaceRoot, ssrOptions } = options; assert( serverEntryPoint, @@ -110,7 +110,15 @@ export function createServerCodeBundleOptions( sourceFileCache, ); - const namespace = 'angular:server-entry'; + const namespace = 'angular:main-server'; + const entryPoints: Record = { + 'main.server': namespace, + }; + + const ssrEntryPoint = ssrOptions?.entry; + if (ssrEntryPoint) { + entryPoints['server'] = ssrEntryPoint; + } const buildOptions: BuildOptions = { ...getEsBuildCommonOptions(options), @@ -131,9 +139,7 @@ export function createServerCodeBundleOptions( `globalThis['require'] ??= createRequire(import.meta.url);`, ].join('\n'), }, - entryPoints: { - 'server': namespace, - }, + entryPoints, supported: getFeatureSupport(target), plugins: [ createSourcemapIngorelistPlugin(), @@ -143,30 +149,6 @@ export function createServerCodeBundleOptions( // Component stylesheet options styleOptions, ), - createVirtualModulePlugin({ - namespace, - loadContent: () => { - const mainServerEntryPoint = path - .relative(workspaceRoot, serverEntryPoint) - .replace(/\\/g, '/'); - const importAndExportDec: string[] = [ - `import '@angular/platform-server/init';`, - `import moduleOrBootstrapFn from './${mainServerEntryPoint}';`, - `export default moduleOrBootstrapFn;`, - `export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`, - ]; - - if (jit) { - importAndExportDec.unshift(`import '@angular/compiler';`); - } - - return { - contents: importAndExportDec.join('\n'), - loader: 'js', - resolveDir: workspaceRoot, - }; - }, - }), ], }; @@ -177,6 +159,33 @@ export function createServerCodeBundleOptions( buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); } + buildOptions.plugins.push( + createVirtualModulePlugin({ + namespace, + loadContent: () => { + const mainServerEntryPoint = path + .relative(workspaceRoot, serverEntryPoint) + .replace(/\\/g, '/'); + const importAndExportDec: string[] = [ + `import '@angular/platform-server/init';`, + `import moduleOrBootstrapFn from './${mainServerEntryPoint}';`, + `export default moduleOrBootstrapFn;`, + `export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`, + ]; + + if (jit) { + importAndExportDec.unshift(`import '@angular/compiler';`); + } + + return { + contents: importAndExportDec.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + return buildOptions; } diff --git a/packages/angular_devkit/build_angular/src/utils/ssg/render-worker.ts b/packages/angular_devkit/build_angular/src/utils/ssg/render-worker.ts index ecf0c5b620..be995951f3 100644 --- a/packages/angular_devkit/build_angular/src/utils/ssg/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/ssg/render-worker.ts @@ -63,7 +63,7 @@ async function render({ route, serverContext }: RenderOptions): Promise('./server.mjs'); + } = await loadEsmModule('./main.server.mjs'); assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported.`); diff --git a/tests/legacy-cli/e2e_runner.ts b/tests/legacy-cli/e2e_runner.ts index 47f12c40bb..5046af7ab7 100644 --- a/tests/legacy-cli/e2e_runner.ts +++ b/tests/legacy-cli/e2e_runner.ts @@ -375,14 +375,17 @@ async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> { }), ); - return pkgs.reduce((all, pkg, i) => { - const json = pkgJsons[i].toString('utf8'); - const { name, version } = JSON.parse(json); - if (!name) { - throw new Error(`Package ${pkg} - package.json name/version not found`); - } + return pkgs.reduce( + (all, pkg, i) => { + const json = pkgJsons[i].toString('utf8'); + const { name, version } = JSON.parse(json); + if (!name) { + throw new Error(`Package ${pkg} - package.json name/version not found`); + } - all[name] = { path: realpathSync(pkg), name, version }; - return all; - }, {} as { [pkg: string]: PkgInfo }); + all[name] = { path: realpathSync(pkg), name, version }; + return all; + }, + {} as { [pkg: string]: PkgInfo }, + ); }