From 2908020601e627b7c76c6fe8d53e19e8858cd325 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 14 Mar 2023 07:17:16 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): support standalone app-shell generation This commit adds support for generating an app-shell for a standalone application. The `main.server.ts`, will need to export a bootstrapping function that returns a `Promise`. Example ```ts export default () => bootstrapApplication(AppComponent, { providers: [ importProvidersFrom(ServerModule), provideRouter([{ path: 'shell', component: AppShellComponent }]), ], }); ``` --- .../src/builders/app-shell/render-worker.ts | 69 +++++++++++---- .../server/platform-server-exports-loader.ts | 2 +- .../build/app-shell/app-shell-standalone.ts | 84 +++++++++++++++++++ 3 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts diff --git a/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts b/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts index 28af48b5849c..ab328f053a8f 100644 --- a/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import type { Type } from '@angular/core'; -import type * as platformServer from '@angular/platform-server'; +import type { ApplicationRef, StaticProvider, Type } from '@angular/core'; +import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; import assert from 'node:assert'; import { workerData } from 'node:worker_threads'; @@ -19,6 +19,23 @@ const { zonePackage } = workerData as { zonePackage: string; }; +interface ServerBundleExports { + /** An internal token that allows providing extra information about the server context. */ + ɵSERVER_CONTEXT?: typeof ɵSERVER_CONTEXT; + + /** Render an NgModule application. */ + renderModule?: typeof renderModule; + + /** NgModule to render. */ + AppServerModule?: Type; + + /** Method to render a standalone application. */ + renderApplication?: typeof renderApplication; + + /** Standalone application bootstrapping function. */ + default?: () => Promise; +} + /** * A request to render a Server bundle generate by the universal server builder. */ @@ -43,29 +60,45 @@ interface RenderRequest { * @returns A promise that resolves to the render HTML document for the application. */ async function render({ serverBundlePath, document, url }: RenderRequest): Promise { - const { AppServerModule, renderModule, ɵSERVER_CONTEXT } = (await import(serverBundlePath)) as { - renderModule: typeof platformServer.renderModule | undefined; - ɵSERVER_CONTEXT: typeof platformServer.ɵSERVER_CONTEXT | undefined; - AppServerModule: Type | undefined; - }; + const { + ɵSERVER_CONTEXT, + AppServerModule, + renderModule, + renderApplication, + default: bootstrapAppFn, + } = (await import(serverBundlePath)) as ServerBundleExports; - assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`); - assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`); assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`); + const platformProviders: StaticProvider[] = [ + { + provide: ɵSERVER_CONTEXT, + useValue: 'app-shell', + }, + ]; + // Render platform server module - const html = await renderModule(AppServerModule, { + if (bootstrapAppFn) { + assert(renderApplication, `renderApplication was not exported from: ${serverBundlePath}.`); + + return renderApplication(bootstrapAppFn, { + document, + url, + platformProviders, + }); + } + + assert( + AppServerModule, + `Neither an AppServerModule nor a bootstrapping function was exported from: ${serverBundlePath}.`, + ); + assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`); + + return renderModule(AppServerModule, { document, url, - extraProviders: [ - { - provide: ɵSERVER_CONTEXT, - useValue: 'app-shell', - }, - ], + extraProviders: platformProviders, }); - - return html; } /** diff --git a/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts index 51b0c5741374..be0c96eedba8 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts @@ -19,7 +19,7 @@ export default function ( const source = `${content} // EXPORTS added by @angular-devkit/build-angular - export { renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; + export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; `; this.callback(null, source, map); diff --git a/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts new file mode 100644 index 000000000000..c3525a63e1aa --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts @@ -0,0 +1,84 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await appendToFile('src/app/app.component.html', ''); + await ng('generate', 'app-shell', '--project', 'test-project'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + + // TODO(alanagius): update the below once we have a standalone schematic. + await writeMultipleFiles({ + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { RouterOutlet } from '@angular/router'; + + @Component({ + selector: 'app-root', + standalone: true, + template: '', + imports: [RouterOutlet], + }) + export class AppComponent {} + `, + 'src/main.ts': ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { provideRouter } from '@angular/router'; + + import { AppComponent } from './app/app.component'; + + bootstrapApplication(AppComponent, { + providers: [ + provideRouter([]), + ], + }); + `, + 'src/main.server.ts': ` + import { importProvidersFrom } from '@angular/core'; + import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; + import { ServerModule } from '@angular/platform-server'; + + import { provideRouter } from '@angular/router'; + + import { AppShellComponent } from './app/app-shell/app-shell.component'; + import { AppComponent } from './app/app.component'; + + export default () => bootstrapApplication(AppComponent, { + providers: [ + importProvidersFrom(BrowserModule.withServerTransition({ appId: 'app' })), + importProvidersFrom(ServerModule), + provideRouter([{ path: 'shell', component: AppShellComponent }]), + ], + }); + `, + }); + + await ng('run', 'test-project:app-shell:development'); + await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); + + await ng('run', 'test-project:app-shell'); + await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); +}