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 }, + ); }