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
39 changes: 30 additions & 9 deletions packages/angular_devkit/build_angular/src/app-shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
BuilderConfiguration,
BuilderContext,
} from '@angular-devkit/architect';
import { Path, getSystemPath, join, normalize, virtualFs } from '@angular-devkit/core';
import { Path, getSystemPath, join, normalize, resolve, virtualFs } from '@angular-devkit/core';
import { Observable, forkJoin, from, merge, of, throwError } from 'rxjs';
import { concatMap, map, switchMap } from 'rxjs/operators';
import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module';
import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker';
import { BrowserBuilderSchema } from '../browser/schema';
import { BuildWebpackServerSchema } from '../server/schema';
import { BuildWebpackAppShellSchema } from './schema';

Expand All @@ -30,7 +32,9 @@ export class AppShellBuilder implements Builder<BuildWebpackAppShellSchema> {
let success = true;
const subscription = merge(
this.build(options.serverTarget, {}),
this.build(options.browserTarget, { watch: false }),
// Never run the browser target in watch mode.
// If service worker is needed, it will be added in this.renderUniversal();
this.build(options.browserTarget, { watch: false, serviceWorker: false }),
).subscribe((event: BuildEvent) => {
// TODO: once we support a better build event, add support for merging two event streams
// together.
Expand Down Expand Up @@ -109,25 +113,30 @@ export class AppShellBuilder implements Builder<BuildWebpackAppShellSchema> {
});
}

getBrowserIndexOutputPath(options: BuildWebpackAppShellSchema) {
getBrowserBuilderConfig(options: BuildWebpackAppShellSchema) {
const architect = this.context.architect;
const [project, target, configuration] = options.browserTarget.split(':');
const builderConfig = architect.getBuilderConfiguration<BuildWebpackServerSchema>({
const builderConfig = architect.getBuilderConfiguration<BrowserBuilderSchema>({
project,
target,
configuration,
});

return architect.getBuilderDescription(builderConfig).pipe(
concatMap(description => architect.validateBuilderOptions(builderConfig, description)),
map(config => join(normalize(config.options.outputPath), 'index.html')),
);
}

renderUniversal(options: BuildWebpackAppShellSchema): Observable<BuildEvent> {
let browserOptions: BrowserBuilderSchema;
let projectRoot: Path;

return forkJoin(
this.getBrowserIndexOutputPath(options).pipe(
switchMap(browserIndexOutputPath => {
this.getBrowserBuilderConfig(options).pipe(
switchMap(config => {
browserOptions = config.options;
projectRoot = resolve(this.context.workspace.root, config.root);
const browserIndexOutputPath = join(normalize(browserOptions.outputPath), 'index.html');
const path = join(this.context.workspace.root, browserIndexOutputPath);

return this.context.host.read(path).pipe(
Expand All @@ -151,7 +160,7 @@ export class AppShellBuilder implements Builder<BuildWebpackAppShellSchema> {
getSystemPath(serverBundlePath),
).AppServerModuleNgFactory;
const indexHtml = virtualFs.fileBufferToString(indexContent);
const outputPath = join(root, options.outputIndexPath || browserIndexOutputPath);
const outputIndexPath = join(root, options.outputIndexPath || browserIndexOutputPath);

// Render to HTML and overwrite the client index file.
return from(
Expand All @@ -161,9 +170,21 @@ export class AppShellBuilder implements Builder<BuildWebpackAppShellSchema> {
})
.then((html: string) => {
return this.context.host
.write(outputPath, virtualFs.stringToFileBuffer(html))
.write(outputIndexPath, virtualFs.stringToFileBuffer(html))
.toPromise();
})
.then(() => {
if (browserOptions.serviceWorker) {
return augmentAppWithServiceWorker(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if re-running this would introduce a noticeable delay for large projects (with lots of files to hash).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a concern, yeah. I don't think ngsw has a way to just update the hashes for some files though. I don't want to duplicate the hashing logic it uses inside the CLI.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic to hash files and write ngsw.json leaves in angular/angular-cli fwiw (mostly here). It wouldn't be too difiicult to add a function that reads ngsw.json, updates some file hashes and writes it back.

But this is not a very robust solution imo.
The current solution is probably fine for now, but on the long run I would expect a way to schedule "post-build" tasks, which would run after all builders have finished building.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the bit that calculates hashes:

switchMap(configJson => {
const Generator = require(swConfigPath).Generator;
const gen = new Generator(new CliFilesystem(host, outputPath), baseHref);
return gen.process(configJson);
}),

It lives in @angular/service-worker but we import it here to obtain the updated manifest, then write it back.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generator calls CliFilessytem.list() to get the files and CliFilesystem.hash() to hash them. Then the ngsw.json manifest is written to dist further below. So, all the tools needed to read the existins ngsw.json, read and hash files and write the updated ngsw.json back are mostly in this file. (OK, there is a tiny piece of logic; i.e. how to update the hashtable in ngsw.json 😁)

Alternatively, Generator could expose a method to update specific files in an existing ngsw.json. So, then you could read the existing ngsw.json, pass it to Generator.updateHashes() and write the updated ngsw.json back.

But, I guess, that would mean that you need to check if the method is available (and this wouldn't work with older versions). Not sure how much of a problem this would be and whether it is worth it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, that is right. I hadn't noticed it.

@alxhub do you have a suggestion on what the best approach is? I think you were the one that wrote the original code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is worth optimizing just yet.

Honestly, the "correct" solution to me is to not generate ngsw.json until the entire build is done, including the App Shell. Running it twice seems like a hack (but a hack that will work).

this.context.host,
root,
projectRoot,
join(root, browserOptions.outputPath),
browserOptions.baseHref || '/',
browserOptions.ngswConfigPath,
);
}
})
.then(() => ({ success: true })),
);
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,249 @@
* 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
*/
// tslint:disable:no-big-function

import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing';
import { normalize, virtualFs } from '@angular-devkit/core';
import { tap } from 'rxjs/operators';
import { host } from '../utils';
import { getSystemPath, join, normalize, virtualFs } from '@angular-devkit/core';
import * as express from 'express'; // tslint:disable-line:no-implicit-dependencies
import { Server } from 'http';
import { concatMap, tap } from 'rxjs/operators';
import { host, protractorTargetSpec } from '../utils';


describe('AppShell Builder', () => {
beforeEach(done => host.initialize().toPromise().then(done, done.fail));
afterEach(done => host.restore().toPromise().then(done, done.fail));

const targetSpec = { project: 'app', target: 'app-shell' };
const appShellRouteFiles = {
'src/app/app-shell/app-shell.component.html': `
<p>
app-shell works!
</p>
`,
'src/app/app-shell/app-shell.component.ts': `
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-app-shell',
templateUrl: './app-shell.component.html',
})
export class AppShellComponent implements OnInit {

constructor() { }

ngOnInit() {
}

}
`,
'src/app/app.module.ts': `
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment';
import { RouterModule } from '@angular/router';

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }),
AppRoutingModule,
RouterModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
`,
'src/app/app.server.module.ts': `
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { Routes, RouterModule } from '@angular/router';
import { AppShellComponent } from './app-shell/app-shell.component';

const routes: Routes = [ { path: 'shell', component: AppShellComponent }];

@NgModule({
imports: [
AppModule,
ServerModule,
RouterModule.forRoot(routes),
],
bootstrap: [AppComponent],
declarations: [AppShellComponent],
})
export class AppServerModule {}
`,
'src/main.ts': `
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
enableProdMode();
}

document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err));
});
`,
'src/app/app-routing.module.ts': `
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [];

@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
`,
'src/app/app.component.html': `
<router-outlet></router-outlet>
`,
};

it('works (basic)', done => {
host.replaceInFile('src/app/app.module.ts', / BrowserModule/, `
BrowserModule.withServerTransition({ appId: 'some-app' })
`);

runTargetSpec(host, targetSpec, {}, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toMatch(/Welcome to app!/);
}),
).toPromise().then(done, done.fail);
});

it('works with route', done => {
host.writeMultipleFiles(appShellRouteFiles);
const overrides = { route: 'shell' };

runTargetSpec(host, targetSpec, overrides, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toContain('app-shell works!');
}),
).toPromise().then(done, done.fail);
});

it('works with route and service-worker', done => {
host.writeMultipleFiles(appShellRouteFiles);
host.writeMultipleFiles({
'src/app/app.server.module.ts': `
'src/ngsw-config.json': `
{
"index": "/index.html",
"assetGroups": [{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**"
]
}
}]
}
`,
'src/app/app.module.ts': `
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { RouterModule } from '@angular/router';

@NgModule({
declarations: [
AppComponent
],
imports: [
AppModule,
ServerModule,
BrowserModule.withServerTransition({ appId: 'serverApp' }),
AppRoutingModule,
ServiceWorkerModule.register('/ngsw-worker.js', { enabled: environment.production }),
RouterModule
],
bootstrap: [AppComponent],
providers: [],
bootstrap: [AppComponent]
})
export class AppServerModule {}
export class AppModule { }
`,
'e2e/app.e2e-spec.ts': `
import { browser, by, element } from 'protractor';

it('should have ngsw in normal state', () => {
browser.get('/');
// Wait for service worker to load.
browser.sleep(2000);
browser.waitForAngularEnabled(false);
browser.get('/ngsw/state');
// Should have updated, and be in normal state.
expect(element(by.css('pre')).getText()).not.toContain('Last update check: never');
expect(element(by.css('pre')).getText()).toContain('Driver state: NORMAL');
});
`,
});
// This should match the browser target prod config.
host.replaceInFile(
'angular.json',
'"buildOptimizer": true',
'"buildOptimizer": true, "serviceWorker": true',
);

const overrides = { route: 'shell' };
const prodTargetSpec = { ...targetSpec, configuration: 'production' };
let server: Server;

runTargetSpec(host, { project: 'app', target: 'app-shell' }, DefaultTimeout * 2).pipe(
// Build the app shell.
runTargetSpec(host, prodTargetSpec, overrides, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
// Make sure the index is pre-rendering the route.
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toMatch(/Welcome to app!/);
expect(content).toContain('app-shell works!');
}),
tap(() => {
// Serve the app using a simple static server.
const app = express();
app.use('/', express.static(getSystemPath(join(host.root(), 'dist')) + '/'));
server = app.listen(4200);
}),
// Load app in protractor, then check service worker status.
concatMap(() => runTargetSpec(host, protractorTargetSpec, { devServerTarget: undefined })),
// Close the express server.
tap(() => server.close()),
).toPromise().then(done, done.fail);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"production": {
"fileReplacements": [
{
"replace": "src/environments/environments.ts",
"with": "src/environments/environments.prod.ts"
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
Expand Down Expand Up @@ -65,6 +65,11 @@
"options": {
"browserTarget": "app:build",
"serverTarget": "app:server"
},
"configurations": {
"production": {
"browserTarget": "app:build:production"
}
}
},
"serve": {
Expand Down