parent
ca129ba549
commit
c30eff898a
|
@ -74,8 +74,7 @@ aot-compiler/**/*.factory.d.ts
|
||||||
!styleguide/src/systemjs.custom.js
|
!styleguide/src/systemjs.custom.js
|
||||||
|
|
||||||
# universal
|
# universal
|
||||||
!universal/webpack.config.client.js
|
!universal/webpack.server.config.js
|
||||||
!universal/webpack.config.universal.js
|
|
||||||
|
|
||||||
# plunkers
|
# plunkers
|
||||||
*plnkr.no-link.html
|
*plnkr.no-link.html
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"projectType": "systemjs"
|
"projectType": "universal"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
// These are important and needed before anything else
|
||||||
|
import 'zone.js/dist/zone-node';
|
||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { enableProdMode } from '@angular/core';
|
||||||
|
|
||||||
|
import * as express from 'express';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Faster server renders w/ Prod mode (dev mode never needed)
|
||||||
|
enableProdMode();
|
||||||
|
|
||||||
|
// Express server
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 4000;
|
||||||
|
const DIST_FOLDER = join(process.cwd(), 'dist');
|
||||||
|
|
||||||
|
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
|
||||||
|
const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/server/main.bundle');
|
||||||
|
|
||||||
|
// Express Engine
|
||||||
|
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||||
|
// Import module map for lazy loading
|
||||||
|
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';
|
||||||
|
|
||||||
|
// #docregion ngExpressEngine
|
||||||
|
app.engine('html', ngExpressEngine({
|
||||||
|
bootstrap: AppServerModuleNgFactory,
|
||||||
|
providers: [
|
||||||
|
provideModuleMap(LAZY_MODULE_MAP)
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
// #enddocregion ngExpressEngine
|
||||||
|
|
||||||
|
app.set('view engine', 'html');
|
||||||
|
app.set('views', join(DIST_FOLDER, 'browser'));
|
||||||
|
|
||||||
|
// #docregion data-request
|
||||||
|
// TODO: implement data requests securely
|
||||||
|
app.get('/api/*', (req, res) => {
|
||||||
|
res.status(404).send('data requests are not supported');
|
||||||
|
});
|
||||||
|
// #enddocregion data-request
|
||||||
|
|
||||||
|
// #docregion static
|
||||||
|
// Server static files from /browser
|
||||||
|
app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));
|
||||||
|
// #enddocregion static
|
||||||
|
|
||||||
|
// #docregion navigation-request
|
||||||
|
// All regular routes use the Universal engine
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.render(join(DIST_FOLDER, 'browser', 'index.html'), { req });
|
||||||
|
});
|
||||||
|
// #enddocregion navigation-request
|
||||||
|
|
||||||
|
// Start up the Node server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Node server listening on http://localhost:${PORT}`);
|
||||||
|
});
|
|
@ -1,16 +1,15 @@
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
import { DashboardComponent } from './dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
import { HeroesComponent } from './heroes.component';
|
import { HeroesComponent } from './heroes/heroes.component';
|
||||||
import { HeroDetailComponent } from './hero-detail.component';
|
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||||
{ path: 'dashboard', component: DashboardComponent },
|
{ path: 'dashboard', component: DashboardComponent },
|
||||||
{ path: 'detail/:id', component: HeroDetailComponent },
|
{ path: 'detail/:id', component: HeroDetailComponent },
|
||||||
{ path: 'heroes', component: HeroesComponent },
|
{ path: 'heroes', component: HeroesComponent }
|
||||||
{ path: '**', redirectTo: '/dashboard' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* AppComponent's private CSS styles */
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<nav>
|
||||||
|
<a routerLink="/dashboard">Dashboard</a>
|
||||||
|
<a routerLink="/heroes">Heroes</a>
|
||||||
|
</nav>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
<app-messages></app-messages>
|
|
@ -1,15 +1,8 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-app',
|
selector: 'app-root',
|
||||||
template: `
|
templateUrl: './app.component.html',
|
||||||
<h1>{{title}}</h1>
|
|
||||||
<nav>
|
|
||||||
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
|
||||||
<a routerLink="/heroes" routerLinkActive="active">Heroes</a>
|
|
||||||
</nav>
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
`,
|
|
||||||
styleUrls: ['./app.component.css']
|
styleUrls: ['./app.component.css']
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
|
|
|
@ -1,62 +1,60 @@
|
||||||
// #docplaster
|
// #docplaster
|
||||||
// #docregion simple
|
import { NgModule } from '@angular/core';
|
||||||
import { NgModule } from '@angular/core';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
|
||||||
|
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
|
||||||
|
|
||||||
// Imports for loading & configuring the in-memory web api
|
|
||||||
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
|
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
|
||||||
import { InMemoryDataService } from './in-memory-data.service';
|
import { InMemoryDataService } from './in-memory-data.service';
|
||||||
|
|
||||||
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { DashboardComponent } from './dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
import { HeroesComponent } from './heroes.component';
|
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
|
||||||
import { HeroDetailComponent } from './hero-detail.component';
|
import { HeroesComponent } from './heroes/heroes.component';
|
||||||
|
import { HeroSearchComponent } from './hero-search/hero-search.component';
|
||||||
import { HeroService } from './hero.service';
|
import { HeroService } from './hero.service';
|
||||||
import { HeroSearchComponent } from './hero-search.component';
|
import { MessageService } from './message.service';
|
||||||
|
import { MessagesComponent } from './messages/messages.component';
|
||||||
|
|
||||||
// #enddocregion simple
|
|
||||||
// #docregion platform-detection
|
// #docregion platform-detection
|
||||||
import { PLATFORM_ID, APP_ID, Inject } from '@angular/core';
|
import { PLATFORM_ID, APP_ID, Inject } from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
// #enddocregion platform-detection
|
// #enddocregion platform-detection
|
||||||
// #docregion simple
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
// #docregion browsermodule
|
// #docregion browsermodule
|
||||||
BrowserModule.withServerTransition({ appId: 'uni' }),
|
BrowserModule.withServerTransition({ appId: 'tour-of-heroes' }),
|
||||||
// #enddocregion browsermodule
|
// #enddocregion browsermodule
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
AppRoutingModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService),
|
HttpClientInMemoryWebApiModule.forRoot(
|
||||||
AppRoutingModule
|
InMemoryDataService, { dataEncapsulation: false }
|
||||||
|
)
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
DashboardComponent,
|
DashboardComponent,
|
||||||
HeroDetailComponent,
|
|
||||||
HeroesComponent,
|
HeroesComponent,
|
||||||
|
HeroDetailComponent,
|
||||||
|
MessagesComponent,
|
||||||
HeroSearchComponent
|
HeroSearchComponent
|
||||||
],
|
],
|
||||||
providers: [ HeroService ],
|
providers: [ HeroService, MessageService ],
|
||||||
bootstrap: [ AppComponent ]
|
bootstrap: [ AppComponent ]
|
||||||
})
|
})
|
||||||
export class AppModule {
|
export class AppModule {
|
||||||
|
// #docregion platform-detection
|
||||||
// #enddocregion simple
|
constructor(
|
||||||
// #docregion platform-detection
|
|
||||||
constructor(
|
|
||||||
@Inject(PLATFORM_ID) private platformId: Object,
|
@Inject(PLATFORM_ID) private platformId: Object,
|
||||||
@Inject(APP_ID) private appId: string) {
|
@Inject(APP_ID) private appId: string) {
|
||||||
const platform = isPlatformBrowser(platformId) ?
|
const platform = isPlatformBrowser(platformId) ?
|
||||||
'on the server' : 'in the browser';
|
'on the server' : 'in the browser';
|
||||||
console.log(`Running ${platform} with appId=${appId}`);
|
console.log(`Running ${platform} with appId=${appId}`);
|
||||||
}
|
}
|
||||||
// #enddocregion platform-detection
|
// #enddocregion platform-detection
|
||||||
// #docregion simple
|
|
||||||
}
|
}
|
||||||
// #enddocregion simple
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import {NgModule} from '@angular/core';
|
||||||
|
import {ServerModule} from '@angular/platform-server';
|
||||||
|
import {ModuleMapLoaderModule} from '@nguniversal/module-map-ngfactory-loader';
|
||||||
|
|
||||||
|
import {AppModule} from './app.module';
|
||||||
|
import {AppComponent} from './app.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
AppModule,
|
||||||
|
ServerModule,
|
||||||
|
ModuleMapLoaderModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Add universal-only providers here
|
||||||
|
],
|
||||||
|
bootstrap: [ AppComponent ],
|
||||||
|
})
|
||||||
|
export class AppServerModule {}
|
|
@ -1,23 +0,0 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
|
||||||
import { HeroService } from './hero.service';
|
|
||||||
|
|
||||||
import 'rxjs/add/operator/map';
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'my-dashboard',
|
|
||||||
templateUrl: './dashboard.component.html',
|
|
||||||
styleUrls: [ './dashboard.component.css' ]
|
|
||||||
})
|
|
||||||
export class DashboardComponent implements OnInit {
|
|
||||||
heroes: Observable<Hero[]>;
|
|
||||||
|
|
||||||
constructor(private heroService: HeroService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.heroes = this.heroService.getHeroes()
|
|
||||||
.map(heroes => heroes.slice(1, 5));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* DashboardComponent's private CSS styles */
|
||||||
[class*='col-'] {
|
[class*='col-'] {
|
||||||
float: left;
|
float: left;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
|
@ -1,9 +1,11 @@
|
||||||
<h3>Top Heroes</h3>
|
<h3>Top Heroes</h3>
|
||||||
<div class="grid grid-pad">
|
<div class="grid grid-pad">
|
||||||
<a *ngFor="let hero of heroes | async" [routerLink]="['/detail', hero.id]" class="col-1-4">
|
<a *ngFor="let hero of heroes" class="col-1-4"
|
||||||
|
routerLink="/detail/{{hero.id}}">
|
||||||
<div class="module hero">
|
<div class="module hero">
|
||||||
<h4>{{hero.name}}</h4>
|
<h4>{{hero.name}}</h4>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hero-search></hero-search>
|
<hero-search></hero-search>
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DashboardComponent } from './dashboard.component';
|
||||||
|
|
||||||
|
describe('DashboardComponent', () => {
|
||||||
|
let component: DashboardComponent;
|
||||||
|
let fixture: ComponentFixture<DashboardComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ DashboardComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DashboardComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { Hero } from '../hero';
|
||||||
|
import { HeroService } from '../hero.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
templateUrl: './dashboard.component.html',
|
||||||
|
styleUrls: [ './dashboard.component.css' ]
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit {
|
||||||
|
heroes: Hero[] = [];
|
||||||
|
|
||||||
|
constructor(private heroService: HeroService) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.getHeroes();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeroes(): void {
|
||||||
|
this.heroService.getHeroes()
|
||||||
|
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +0,0 @@
|
||||||
<div *ngIf="hero">
|
|
||||||
<h2>{{hero.name}} details!</h2>
|
|
||||||
<div>
|
|
||||||
<label>id: </label>{{hero.id}}</div>
|
|
||||||
<div>
|
|
||||||
<label>name: </label>
|
|
||||||
<input [(ngModel)]="hero.name" placeholder="name" />
|
|
||||||
</div>
|
|
||||||
<button (click)="goBack()">Back</button>
|
|
||||||
<button (click)="save()">Save</button>
|
|
||||||
</div>
|
|
|
@ -1,38 +0,0 @@
|
||||||
import 'rxjs/add/operator/switchMap';
|
|
||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
|
||||||
import { Location } from '@angular/common';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
|
||||||
import { HeroService } from './hero.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'my-hero-detail',
|
|
||||||
templateUrl: './hero-detail.component.html',
|
|
||||||
styleUrls: [ './hero-detail.component.css' ]
|
|
||||||
})
|
|
||||||
export class HeroDetailComponent implements OnInit {
|
|
||||||
hero: Hero;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private heroService: HeroService,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private location: Location
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.route.paramMap
|
|
||||||
.switchMap((params: ParamMap) => this.heroService.getHero(+params.get('id')))
|
|
||||||
.subscribe(hero => this.hero = hero);
|
|
||||||
}
|
|
||||||
|
|
||||||
save(): void {
|
|
||||||
this.heroService
|
|
||||||
.update(this.hero)
|
|
||||||
.subscribe(() => this.goBack());
|
|
||||||
}
|
|
||||||
|
|
||||||
goBack(): void {
|
|
||||||
this.location.back();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* HeroDetailComponent's private CSS styles */
|
||||||
label {
|
label {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 3em;
|
width: 3em;
|
|
@ -0,0 +1,11 @@
|
||||||
|
<div *ngIf="hero">
|
||||||
|
<h2>{{ hero.name | uppercase }} Details</h2>
|
||||||
|
<div><span>id: </span>{{hero.id}}</div>
|
||||||
|
<div>
|
||||||
|
<label>name:
|
||||||
|
<input [(ngModel)]="hero.name" placeholder="name"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button (click)="goBack()">go back</button>
|
||||||
|
<button (click)="save()">save</button>
|
||||||
|
</div>
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Component, OnInit, Input } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
|
||||||
|
import { Hero } from '../hero';
|
||||||
|
import { HeroService } from '../hero.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hero-detail',
|
||||||
|
templateUrl: './hero-detail.component.html',
|
||||||
|
styleUrls: [ './hero-detail.component.css' ]
|
||||||
|
})
|
||||||
|
export class HeroDetailComponent implements OnInit {
|
||||||
|
@Input() hero: Hero;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private heroService: HeroService,
|
||||||
|
private location: Location
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.getHero();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHero(): void {
|
||||||
|
const id = +this.route.snapshot.paramMap.get('id');
|
||||||
|
this.heroService.getHero(id)
|
||||||
|
.subscribe(hero => this.hero = hero);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
this.heroService.updateHero(this.hero)
|
||||||
|
.subscribe(() => this.goBack());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
.search-result{
|
|
||||||
border-bottom: 1px solid gray;
|
|
||||||
border-left: 1px solid gray;
|
|
||||||
border-right: 1px solid gray;
|
|
||||||
width:195px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 5px;
|
|
||||||
background-color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result:hover {
|
|
||||||
color: #eee;
|
|
||||||
background-color: #607D8B;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-box{
|
|
||||||
width: 200px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
<div id="search-component">
|
|
||||||
<h4>Hero Search</h4>
|
|
||||||
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
|
|
||||||
<div>
|
|
||||||
<div *ngFor="let hero of heroes | async"
|
|
||||||
(click)="gotoDetail(hero)" class="search-result" >
|
|
||||||
{{hero.name}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,55 +0,0 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
|
||||||
import { Subject } from 'rxjs/Subject';
|
|
||||||
|
|
||||||
import 'rxjs/add/observable/of';
|
|
||||||
|
|
||||||
import 'rxjs/add/operator/catch';
|
|
||||||
import 'rxjs/add/operator/debounceTime';
|
|
||||||
import 'rxjs/add/operator/distinctUntilChanged';
|
|
||||||
|
|
||||||
import { HeroSearchService } from './hero-search.service';
|
|
||||||
import { Hero } from './hero';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'hero-search',
|
|
||||||
templateUrl: './hero-search.component.html',
|
|
||||||
styleUrls: [ './hero-search.component.css' ],
|
|
||||||
providers: [HeroSearchService]
|
|
||||||
})
|
|
||||||
export class HeroSearchComponent implements OnInit {
|
|
||||||
heroes: Observable<Hero[]>;
|
|
||||||
private searchTerms = new Subject<string>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private heroSearchService: HeroSearchService,
|
|
||||||
private router: Router) {}
|
|
||||||
|
|
||||||
// Push a search term into the observable stream.
|
|
||||||
search(term: string): void {
|
|
||||||
this.searchTerms.next(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.heroes = this.searchTerms.asObservable()
|
|
||||||
.debounceTime(300) // wait 300ms after each keystroke before considering the term
|
|
||||||
.distinctUntilChanged() // ignore if next search term is same as previous
|
|
||||||
.switchMap(term => term // switch to new observable each time the term changes
|
|
||||||
// return the http search observable
|
|
||||||
? this.heroSearchService.search(term)
|
|
||||||
// or the observable of empty heroes if there was no search term
|
|
||||||
: Observable.of<Hero[]>([]))
|
|
||||||
.catch(error => {
|
|
||||||
// TODO: add real error handling
|
|
||||||
console.log(error);
|
|
||||||
return Observable.of<Hero[]>([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
gotoDetail(hero: Hero): void {
|
|
||||||
let link = ['/detail', hero.id];
|
|
||||||
this.router.navigate(link);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { Inject, Injectable, Optional } from '@angular/core';
|
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
|
||||||
import 'rxjs/add/operator/map';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
|
||||||
|
|
||||||
// #docregion class
|
|
||||||
@Injectable()
|
|
||||||
export class HeroSearchService {
|
|
||||||
|
|
||||||
private searchUrl = 'api/heroes/?name='; // URL to web api
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private http: HttpClient,
|
|
||||||
@Optional() @Inject(APP_BASE_HREF) origin: string) {
|
|
||||||
this.searchUrl = (origin || '') + this.searchUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
search(term: string): Observable<Hero[]> {
|
|
||||||
return this.http
|
|
||||||
.get(this.searchUrl + term)
|
|
||||||
.map((data: any) => data.data as Hero[]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #enddocregion class
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
/* HeroSearch private styles */
|
||||||
|
.search-result li {
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
border-left: 1px solid gray;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
width:195px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 5px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result li:hover {
|
||||||
|
background-color: #607D8B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result li a {
|
||||||
|
color: #888;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result li a:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.search-result li a:active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#search-box {
|
||||||
|
width: 200px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul.search-result {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
<div id="search-component">
|
||||||
|
<h4>Hero Search</h4>
|
||||||
|
|
||||||
|
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
|
||||||
|
|
||||||
|
<ul class="search-result">
|
||||||
|
<li *ngFor="let hero of heroes | async" >
|
||||||
|
<a routerLink="/detail/{{hero.id}}">
|
||||||
|
{{hero.name}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HeroSearchComponent } from './hero-search.component';
|
||||||
|
|
||||||
|
describe('HeroSearchComponent', () => {
|
||||||
|
let component: HeroSearchComponent;
|
||||||
|
let fixture: ComponentFixture<HeroSearchComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ HeroSearchComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(HeroSearchComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { Subject } from 'rxjs/Subject';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
|
||||||
|
import {
|
||||||
|
debounceTime, distinctUntilChanged, switchMap
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { Hero } from '../hero';
|
||||||
|
import { HeroService } from '../hero.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'hero-search',
|
||||||
|
templateUrl: './hero-search.component.html',
|
||||||
|
styleUrls: [ './hero-search.component.css' ]
|
||||||
|
})
|
||||||
|
export class HeroSearchComponent implements OnInit {
|
||||||
|
heroes: Observable<Hero[]>;
|
||||||
|
private searchTerms = new Subject<string>();
|
||||||
|
|
||||||
|
constructor(private heroService: HeroService) {}
|
||||||
|
|
||||||
|
// Push a search term into the observable stream.
|
||||||
|
search(term: string): void {
|
||||||
|
this.searchTerms.next(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.heroes = this.searchTerms.pipe(
|
||||||
|
// wait 300ms after each keystroke before considering the term
|
||||||
|
debounceTime(300),
|
||||||
|
|
||||||
|
// ignore new term if same as previous term
|
||||||
|
distinctUntilChanged(),
|
||||||
|
|
||||||
|
// switch to new search observable each time the term changes
|
||||||
|
switchMap((term: string) => this.heroService.searchHeroes(term)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,17 @@
|
||||||
import { Injectable, Inject, Optional } from '@angular/core';
|
import { Injectable, Inject, Optional } from '@angular/core';
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
import { HttpHeaders, HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpHeaders }from '@angular/common/http';
|
||||||
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import 'rxjs/add/operator/catch';
|
import { of } from 'rxjs/observable/of';
|
||||||
import 'rxjs/add/operator/do';
|
import { catchError, map, tap } from 'rxjs/operators';
|
||||||
import 'rxjs/add/operator/map';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
import { Hero } from './hero';
|
||||||
|
import { MessageService } from './message.service';
|
||||||
|
|
||||||
const headers = new HttpHeaders({'Content-Type': 'application/json'});
|
const httpOptions = {
|
||||||
|
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HeroService {
|
export class HeroService {
|
||||||
|
@ -19,46 +21,109 @@ export class HeroService {
|
||||||
// #docregion ctor
|
// #docregion ctor
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
|
private messageService: MessageService,
|
||||||
@Optional() @Inject(APP_BASE_HREF) origin: string) {
|
@Optional() @Inject(APP_BASE_HREF) origin: string) {
|
||||||
this.heroesUrl = (origin || '') + this.heroesUrl;
|
this.heroesUrl = `${origin}${this.heroesUrl}`;
|
||||||
}
|
}
|
||||||
// #enddocregion ctor
|
// #enddocregion ctor
|
||||||
|
|
||||||
getHeroes(): Observable<Hero[]> {
|
/** GET heroes from the server */
|
||||||
return this.http.get(this.heroesUrl)
|
getHeroes (): Observable<Hero[]> {
|
||||||
.map((data: any) => data.data as Hero[])
|
return this.http.get<Hero[]>(this.heroesUrl)
|
||||||
.catch(this.handleError);
|
.pipe(
|
||||||
|
tap(heroes => this.log(`fetched heroes`)),
|
||||||
|
catchError(this.handleError('getHeroes', []))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET hero by id. Return `undefined` when id not found */
|
||||||
|
getHeroNo404<Data>(id: number): Observable<Hero> {
|
||||||
|
const url = `${this.heroesUrl}/?id=${id}`;
|
||||||
|
return this.http.get<Hero[]>(url)
|
||||||
|
.pipe(
|
||||||
|
map(heroes => heroes[0]), // returns a {0|1} element array
|
||||||
|
tap(h => {
|
||||||
|
const outcome = h ? `fetched` : `did not find`;
|
||||||
|
this.log(`${outcome} hero id=${id}`);
|
||||||
|
}),
|
||||||
|
catchError(this.handleError<Hero>(`getHero id=${id}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET hero by id. Will 404 if id not found */
|
||||||
getHero(id: number): Observable<Hero> {
|
getHero(id: number): Observable<Hero> {
|
||||||
const url = `${this.heroesUrl}/${id}`;
|
const url = `${this.heroesUrl}/${id}`;
|
||||||
return this.http.get(url)
|
return this.http.get<Hero>(url).pipe(
|
||||||
.map((data: any) => data.data as Hero)
|
tap(_ => this.log(`fetched hero id=${id}`)),
|
||||||
.catch(this.handleError);
|
catchError(this.handleError<Hero>(`getHero id=${id}`))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: number): Observable<void> {
|
/* GET heroes whose name contains search term */
|
||||||
|
searchHeroes(term: string): Observable<Hero[]> {
|
||||||
|
if (!term.trim()) {
|
||||||
|
// if not search term, return empty hero array.
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
|
||||||
|
tap(_ => this.log(`found heroes matching "${term}"`)),
|
||||||
|
catchError(this.handleError<Hero[]>('searchHeroes', []))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//////// Save methods //////////
|
||||||
|
|
||||||
|
/** POST: add a new hero to the server */
|
||||||
|
addHero (name: string): Observable<Hero> {
|
||||||
|
const hero = { name };
|
||||||
|
|
||||||
|
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
|
||||||
|
tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
|
||||||
|
catchError(this.handleError<Hero>('addHero'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE: delete the hero from the server */
|
||||||
|
deleteHero (hero: Hero | number): Observable<Hero> {
|
||||||
|
const id = typeof hero === 'number' ? hero : hero.id;
|
||||||
const url = `${this.heroesUrl}/${id}`;
|
const url = `${this.heroesUrl}/${id}`;
|
||||||
return this.http.delete(url, { headers })
|
|
||||||
.catch(this.handleError);
|
return this.http.delete<Hero>(url, httpOptions).pipe(
|
||||||
|
tap(_ => this.log(`deleted hero id=${id}`)),
|
||||||
|
catchError(this.handleError<Hero>('deleteHero'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(name: string): Observable<Hero> {
|
/** PUT: update the hero on the server */
|
||||||
return this.http
|
updateHero (hero: Hero): Observable<any> {
|
||||||
.post(this.heroesUrl, { name: name }, { headers })
|
return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
|
||||||
.map((data: any) => data.data)
|
tap(_ => this.log(`updated hero id=${hero.id}`)),
|
||||||
.catch(this.handleError);
|
catchError(this.handleError<any>('updateHero'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(hero: Hero): Observable<Hero> {
|
/**
|
||||||
const url = `${this.heroesUrl}/${hero.id}`;
|
* Handle Http operation that failed.
|
||||||
return this.http
|
* Let the app continue.
|
||||||
.put(url, hero, { headers })
|
* @param operation - name of the operation that failed
|
||||||
.catch(this.handleError);
|
* @param result - optional value to return as the observable result
|
||||||
|
*/
|
||||||
|
private handleError<T> (operation = 'operation', result?: T) {
|
||||||
|
return (error: any): Observable<T> => {
|
||||||
|
|
||||||
|
// TODO: send the error to remote logging infrastructure
|
||||||
|
console.error(error); // log to console instead
|
||||||
|
|
||||||
|
// TODO: better job of transforming error for user consumption
|
||||||
|
this.log(`${operation} failed: ${error.message}`);
|
||||||
|
|
||||||
|
// Let the app keep running by returning an empty result.
|
||||||
|
return of(result as T);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(error: any): Observable<any> {
|
/** Log a HeroService message with the MessageService */
|
||||||
console.error('An error occurred', error); // for demo purposes only
|
private log(message: string) {
|
||||||
throw error;
|
this.messageService.add('HeroService: ' + message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
<!-- #docregion -->
|
|
||||||
<h2>My Heroes</h2>
|
|
||||||
<!-- #docregion add -->
|
|
||||||
<div>
|
|
||||||
<label>Hero name:</label> <input #heroName />
|
|
||||||
<button (click)="add(heroName.value); heroName.value=''">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- #enddocregion add -->
|
|
||||||
<ul class="heroes">
|
|
||||||
<!-- #docregion li-element -->
|
|
||||||
<li *ngFor="let hero of heroes" (click)="onSelect(hero)"
|
|
||||||
[class.selected]="hero === selectedHero">
|
|
||||||
<span class="badge">{{hero.id}}</span>
|
|
||||||
<span>{{hero.name}}</span>
|
|
||||||
<!-- #docregion delete -->
|
|
||||||
<button class="delete"
|
|
||||||
(click)="delete(hero); $event.stopPropagation()">x</button>
|
|
||||||
<!-- #enddocregion delete -->
|
|
||||||
</li>
|
|
||||||
<!-- #enddocregion li-element -->
|
|
||||||
</ul>
|
|
||||||
<div *ngIf="selectedHero">
|
|
||||||
<h2>
|
|
||||||
{{selectedHero.name | uppercase}} is my hero
|
|
||||||
</h2>
|
|
||||||
<button (click)="gotoDetail()">View Details</button>
|
|
||||||
</div>
|
|
|
@ -1,57 +0,0 @@
|
||||||
// #docregion
|
|
||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
|
||||||
import { HeroService } from './hero.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'my-heroes',
|
|
||||||
templateUrl: './heroes.component.html',
|
|
||||||
styleUrls: [ './heroes.component.css' ]
|
|
||||||
})
|
|
||||||
export class HeroesComponent implements OnInit {
|
|
||||||
heroes: Hero[];
|
|
||||||
selectedHero: Hero;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private heroService: HeroService,
|
|
||||||
private router: Router) { }
|
|
||||||
|
|
||||||
getHeroes(): void {
|
|
||||||
this.heroService
|
|
||||||
.getHeroes()
|
|
||||||
.subscribe(heroes => this.heroes = heroes);
|
|
||||||
}
|
|
||||||
|
|
||||||
add(name: string): void {
|
|
||||||
name = name.trim();
|
|
||||||
if (!name) { return; }
|
|
||||||
this.heroService.create(name)
|
|
||||||
.subscribe(hero => {
|
|
||||||
this.heroes.push(hero);
|
|
||||||
this.selectedHero = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(hero: Hero): void {
|
|
||||||
this.heroService
|
|
||||||
.delete(hero.id)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.heroes = this.heroes.filter(h => h !== hero);
|
|
||||||
if (this.selectedHero === hero) { this.selectedHero = null; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.getHeroes();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect(hero: Hero): void {
|
|
||||||
this.selectedHero = hero;
|
|
||||||
}
|
|
||||||
|
|
||||||
gotoDetail(): void {
|
|
||||||
this.router.navigate(['/detail', this.selectedHero.id]);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +1,4 @@
|
||||||
/* #docregion */
|
/* HeroesComponent's private CSS styles */
|
||||||
.selected {
|
|
||||||
background-color: #CFD8DC !important;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.heroes {
|
.heroes {
|
||||||
margin: 0 0 2em 0;
|
margin: 0 0 2em 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
@ -10,28 +6,33 @@
|
||||||
width: 15em;
|
width: 15em;
|
||||||
}
|
}
|
||||||
.heroes li {
|
.heroes li {
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
left: 0;
|
cursor: pointer;
|
||||||
background-color: #EEE;
|
background-color: #EEE;
|
||||||
margin: .5em;
|
margin: .5em;
|
||||||
padding: .3em 0;
|
padding: .3em 0;
|
||||||
height: 1.6em;
|
height: 1.6em;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroes li:hover {
|
.heroes li:hover {
|
||||||
color: #607D8B;
|
color: #607D8B;
|
||||||
background-color: #DDD;
|
background-color: #DDD;
|
||||||
left: .1em;
|
left: .1em;
|
||||||
}
|
}
|
||||||
.heroes li.selected:hover {
|
|
||||||
background-color: #BBD8DC !important;
|
.heroes a {
|
||||||
color: white;
|
color: #888;
|
||||||
}
|
text-decoration: none;
|
||||||
.heroes .text {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -3px;
|
display: block;
|
||||||
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroes a:hover {
|
||||||
|
color:#607D8B;
|
||||||
|
}
|
||||||
|
|
||||||
.heroes .badge {
|
.heroes .badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: small;
|
font-size: small;
|
||||||
|
@ -43,26 +44,31 @@
|
||||||
left: -1px;
|
left: -1px;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
height: 1.8em;
|
height: 1.8em;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: right;
|
||||||
margin-right: .8em;
|
margin-right: .8em;
|
||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
font-family: Arial;
|
.button {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
cursor: hand;
|
cursor: hand;
|
||||||
|
font-family: Arial;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background-color: #cfd8dc;
|
background-color: #cfd8dc;
|
||||||
}
|
}
|
||||||
/* #docregion additions */
|
|
||||||
button.delete {
|
button.delete {
|
||||||
float:right;
|
position: relative;
|
||||||
margin-top: 2px;
|
left: 194px;
|
||||||
margin-right: .8em;
|
top: -32px;
|
||||||
background-color: gray !important;
|
background-color: gray !important;
|
||||||
color:white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
<h2>My Heroes</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Hero name:
|
||||||
|
<input #heroName />
|
||||||
|
</label>
|
||||||
|
<!-- (click) passes input value to add() and then clears the input -->
|
||||||
|
<button (click)="add(heroName.value); heroName.value=''">
|
||||||
|
add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="heroes">
|
||||||
|
<li *ngFor="let hero of heroes">
|
||||||
|
<a routerLink="/detail/{{hero.id}}">
|
||||||
|
<span class="badge">{{hero.id}}</span> {{hero.name}}
|
||||||
|
</a>
|
||||||
|
<button class="delete" title="delete hero"
|
||||||
|
(click)="delete(hero);$event.stopPropagation()">x</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HeroesComponent } from './heroes.component';
|
||||||
|
|
||||||
|
describe('HeroesComponent', () => {
|
||||||
|
let component: HeroesComponent;
|
||||||
|
let fixture: ComponentFixture<HeroesComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ HeroesComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(HeroesComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { Hero } from '../hero';
|
||||||
|
import { HeroService } from '../hero.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-heroes',
|
||||||
|
templateUrl: './heroes.component.html',
|
||||||
|
styleUrls: ['./heroes.component.css']
|
||||||
|
})
|
||||||
|
export class HeroesComponent implements OnInit {
|
||||||
|
heroes: Hero[];
|
||||||
|
|
||||||
|
constructor(private heroService: HeroService) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.getHeroes();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeroes(): void {
|
||||||
|
this.heroService.getHeroes()
|
||||||
|
.subscribe(heroes => this.heroes = heroes);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(name: string): void {
|
||||||
|
name = name.trim();
|
||||||
|
if (!name) { return; }
|
||||||
|
this.heroService.addHero(name)
|
||||||
|
.subscribe(hero => {
|
||||||
|
this.heroes.push(hero);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(hero: Hero): void {
|
||||||
|
this.heroService.deleteHero(hero)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.heroes = this.heroes.filter(h => h !== hero);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,18 +1,18 @@
|
||||||
// #docregion , init
|
|
||||||
import { InMemoryDbService } from 'angular-in-memory-web-api';
|
import { InMemoryDbService } from 'angular-in-memory-web-api';
|
||||||
|
|
||||||
export class InMemoryDataService implements InMemoryDbService {
|
export class InMemoryDataService implements InMemoryDbService {
|
||||||
createDb() {
|
createDb() {
|
||||||
let heroes = [
|
const heroes = [
|
||||||
{id: 11, name: 'Mr. Nice'},
|
{ id: 11, name: 'Mr. Nice' },
|
||||||
{id: 12, name: 'Narco'},
|
{ id: 12, name: 'Narco' },
|
||||||
{id: 13, name: 'Bombasto'},
|
{ id: 13, name: 'Bombasto' },
|
||||||
{id: 14, name: 'Celeritas'},
|
{ id: 14, name: 'Celeritas' },
|
||||||
{id: 15, name: 'Magneta'},
|
{ id: 15, name: 'Magneta' },
|
||||||
{id: 16, name: 'RubberMan'},
|
{ id: 16, name: 'RubberMan' },
|
||||||
{id: 17, name: 'Dynama'},
|
{ id: 17, name: 'Dynama' },
|
||||||
{id: 18, name: 'Dr IQ'},
|
{ id: 18, name: 'Dr IQ' },
|
||||||
{id: 19, name: 'Magma'},
|
{ id: 19, name: 'Magma' },
|
||||||
{id: 20, name: 'Tornado'}
|
{ id: 20, name: 'Tornado' }
|
||||||
];
|
];
|
||||||
return {heroes};
|
return {heroes};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MessageService } from './message.service';
|
||||||
|
|
||||||
|
describe('MessageService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [MessageService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([MessageService], (service: MessageService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessageService {
|
||||||
|
messages: string[] = [];
|
||||||
|
|
||||||
|
add(message: string) {
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.messages.length = 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/* MessagesComponent's private CSS styles */
|
||||||
|
h2 {
|
||||||
|
color: red;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-weight: lighter;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 2em;
|
||||||
|
}
|
||||||
|
body, input[text], button {
|
||||||
|
color: crimson;
|
||||||
|
font-family: Cambria, Georgia;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.clear {
|
||||||
|
font-family: Arial;
|
||||||
|
background-color: #eee;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
cursor: hand;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #cfd8dc;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background-color: #eee;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
button.clear {
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<div *ngIf="messageService.messages.length">
|
||||||
|
|
||||||
|
<h2>Messages</h2>
|
||||||
|
<button class="clear"
|
||||||
|
(click)="messageService.clear()">clear</button>
|
||||||
|
<div *ngFor='let message of messageService.messages'> {{message}} </div>
|
||||||
|
|
||||||
|
</div>
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MessagesComponent } from './messages.component';
|
||||||
|
|
||||||
|
describe('MessagesComponent', () => {
|
||||||
|
let component: MessagesComponent;
|
||||||
|
let fixture: ComponentFixture<MessagesComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ MessagesComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MessagesComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { MessageService } from '../message.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-messages',
|
||||||
|
templateUrl: './messages.component.html',
|
||||||
|
styleUrls: ['./messages.component.css']
|
||||||
|
})
|
||||||
|
export class MessagesComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor(public messageService: MessageService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Hero } from './hero';
|
||||||
|
|
||||||
|
export const HEROES: Hero[] = [
|
||||||
|
{ id: 11, name: 'Mr. Nice' },
|
||||||
|
{ id: 12, name: 'Narco' },
|
||||||
|
{ id: 13, name: 'Bombasto' },
|
||||||
|
{ id: 14, name: 'Celeritas' },
|
||||||
|
{ id: 15, name: 'Magneta' },
|
||||||
|
{ id: 16, name: 'RubberMan' },
|
||||||
|
{ id: 17, name: 'Dynama' },
|
||||||
|
{ id: 18, name: 'Dr IQ' },
|
||||||
|
{ id: 19, name: 'Magma' },
|
||||||
|
{ id: 20, name: 'Tornado' }
|
||||||
|
];
|
|
@ -1,22 +0,0 @@
|
||||||
<!-- #docregion -->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<base href="/">
|
|
||||||
<title>Angular Universal Tour of Heroes</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
|
|
||||||
<script src="shim.min.js"></script>
|
|
||||||
<script src="zone.min.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<my-app>Loading...</my-app>
|
|
||||||
</body>
|
|
||||||
<!-- #docregion client-app-bundle -->
|
|
||||||
<script src="client.js"></script>
|
|
||||||
<!-- #enddocregion client-app-bundle -->
|
|
||||||
</html>
|
|
|
@ -1,27 +1,14 @@
|
||||||
<!-- #docregion -->
|
<!doctype html>
|
||||||
<!DOCTYPE html>
|
<html lang="en">
|
||||||
<html>
|
<head>
|
||||||
<head>
|
<meta charset="utf-8">
|
||||||
<base href="/">
|
<title>Tour of Heroes</title>
|
||||||
<title>Angular Universal Tour of Heroes</title>
|
<base href="/">
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
|
|
||||||
<!-- Polyfills -->
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<script src="node_modules/core-js/client/shim.min.js"></script>
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<script src="node_modules/zone.js/dist/zone.js"></script>
|
</head>
|
||||||
|
<body>
|
||||||
<script src="node_modules/systemjs/dist/system.src.js"></script>
|
<app-root></app-root>
|
||||||
<script src="systemjs.config.js"></script>
|
</body>
|
||||||
<script>
|
|
||||||
System.import('main.js')
|
|
||||||
.catch(function(err){ console.error(err); });
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<my-app>Loading...</my-app>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export { AppServerModule } from './app/app.server.module';
|
|
@ -1,6 +1,11 @@
|
||||||
// #docregion
|
import { enableProdMode } from '@angular/core';
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
|
if (environment.production) {
|
||||||
|
enableProdMode();
|
||||||
|
}
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule);
|
platformBrowserDynamic().bootstrapModule(AppModule);
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../out-tsc/app",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"test.ts",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"entryModule": "app/app.server.module#AppServerModule"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +0,0 @@
|
||||||
// #docregion
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { ServerModule } from '@angular/platform-server';
|
|
||||||
import { AppComponent } from '../app/app.component';
|
|
||||||
import { AppModule } from '../app/app.module';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
AppModule,
|
|
||||||
ServerModule,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
// Add universal-only providers here
|
|
||||||
],
|
|
||||||
bootstrap: [
|
|
||||||
AppComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AppServerModule {}
|
|
|
@ -1,68 +0,0 @@
|
||||||
// Express Server for Angular Universal app
|
|
||||||
import 'zone.js/dist/zone-node';
|
|
||||||
import * as express from 'express';
|
|
||||||
import { enableProdMode } from '@angular/core';
|
|
||||||
|
|
||||||
// #docregion import-app-server-factory
|
|
||||||
// AppServerModuleNgFactory, generated by AOT webpack plug-in,
|
|
||||||
// exists in-memory during build.
|
|
||||||
// It is not available in the file system at design time
|
|
||||||
import { AppServerModuleNgFactory } from '../../aot/src/universal/app-server.module.ngfactory';
|
|
||||||
// #enddocregion import-app-server-factory
|
|
||||||
|
|
||||||
import { universalEngine } from './universal-engine';
|
|
||||||
|
|
||||||
enableProdMode();
|
|
||||||
|
|
||||||
const port = 3200;
|
|
||||||
const server = express();
|
|
||||||
|
|
||||||
// #docregion universal-engine
|
|
||||||
// Render HTML files with the universal template engine
|
|
||||||
server.engine('html', universalEngine({
|
|
||||||
appModuleFactory: AppServerModuleNgFactory
|
|
||||||
}));
|
|
||||||
|
|
||||||
// engine should find templates in 'dist/' by default
|
|
||||||
server.set('views', 'dist');
|
|
||||||
// #enddocregion universal-engine
|
|
||||||
|
|
||||||
// CRITICAL TODO: add authentication/authorization middleware
|
|
||||||
|
|
||||||
// #docregion data-request
|
|
||||||
// TODO: implement data requests securely
|
|
||||||
server.get('/api/*', (req, res) => {
|
|
||||||
res.status(404).send('data requests are not supported');
|
|
||||||
});
|
|
||||||
// #enddocregion data-request
|
|
||||||
|
|
||||||
// #docregion navigation-request
|
|
||||||
// simplistic regex matches any path without a '.'
|
|
||||||
const pathWithNoExt = /^([^.]*)$/;
|
|
||||||
|
|
||||||
// treat any path without an extension as in-app navigation
|
|
||||||
server.get(pathWithNoExt, (req, res) => {
|
|
||||||
// render with the universal template engine
|
|
||||||
res.render('index-universal.html', { req });
|
|
||||||
});
|
|
||||||
// #enddocregion navigation-request
|
|
||||||
|
|
||||||
// #docregion static
|
|
||||||
// remaining requests are for static files
|
|
||||||
server.use((req, res, next) => {
|
|
||||||
const fileName = req.originalUrl;
|
|
||||||
console.log(fileName);
|
|
||||||
|
|
||||||
// security: only serve files from dist
|
|
||||||
const root = 'dist';
|
|
||||||
|
|
||||||
res.sendFile(fileName, { root }, err => {
|
|
||||||
if (err) { next(err); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// #enddocregion static
|
|
||||||
|
|
||||||
// start the server
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`listening on port ${port}...`);
|
|
||||||
});
|
|
|
@ -1,49 +0,0 @@
|
||||||
/**
|
|
||||||
* Node Express template engine for Universal apps
|
|
||||||
*/
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
import { renderModuleFactory } from '@angular/platform-server';
|
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
|
||||||
|
|
||||||
const templateCache: { [key: string]: string } = {}; // page templates
|
|
||||||
|
|
||||||
export function universalEngine(setupOptions: any) {
|
|
||||||
|
|
||||||
// Express template engine middleware
|
|
||||||
return function (
|
|
||||||
filePath: string,
|
|
||||||
options: { req: Request },
|
|
||||||
callback: (err: Error, html: string) => void) {
|
|
||||||
|
|
||||||
const { req } = options;
|
|
||||||
const routeUrl = req.url;
|
|
||||||
|
|
||||||
let template = templateCache[filePath];
|
|
||||||
if (!template) {
|
|
||||||
template = fs.readFileSync(filePath).toString();
|
|
||||||
templateCache[filePath] = template;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { appModuleFactory } = setupOptions;
|
|
||||||
const origin = getOrigin(req);
|
|
||||||
|
|
||||||
// #docregion render
|
|
||||||
// render the page
|
|
||||||
renderModuleFactory(appModuleFactory, {
|
|
||||||
document: template,
|
|
||||||
url: routeUrl,
|
|
||||||
extraProviders: [
|
|
||||||
{ provide: APP_BASE_HREF, useValue: origin }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.then(page => callback(null, page));
|
|
||||||
// #enddocregion render
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrigin(req: Request) {
|
|
||||||
// e.g., http://localhost:3200/
|
|
||||||
return `${req.protocol}://${req.hostname}:${req.connection.address().port}/`;
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./tsconfig.universal.json",
|
|
||||||
|
|
||||||
"files": [
|
|
||||||
"src/main.ts"
|
|
||||||
],
|
|
||||||
|
|
||||||
"angularCompilerOptions": {
|
|
||||||
"genDir": "aot",
|
|
||||||
"entryModule": "./src/app/app.module#AppModule",
|
|
||||||
"skipMetadataEmit" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"module": "es2015",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"sourceMap": true,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"lib": ["es2015", "dom"],
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"suppressImplicitAnyIndexErrors": true,
|
|
||||||
"typeRoots": [
|
|
||||||
"./node_modules/@types/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"files": [
|
|
||||||
"src/universal/app-server.module.ts",
|
|
||||||
"src/universal/server.ts"
|
|
||||||
],
|
|
||||||
|
|
||||||
"angularCompilerOptions": {
|
|
||||||
"genDir": "aot",
|
|
||||||
"entryModule": "./src/app/app.module#AppModule",
|
|
||||||
"skipMetadataEmit" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
// #docregion
|
|
||||||
const ngtools = require('@ngtools/webpack');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
devtool: 'source-map',
|
|
||||||
entry: {
|
|
||||||
main: [ './src/main.ts' ]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['.ts', '.js']
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
path: __dirname + '/dist',
|
|
||||||
filename: 'client.js'
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
// compile with AOT
|
|
||||||
new ngtools.AotPlugin({
|
|
||||||
tsConfigPath: './tsconfig.client.json'
|
|
||||||
}),
|
|
||||||
|
|
||||||
// minify
|
|
||||||
new UglifyJSPlugin()
|
|
||||||
],
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{ test: /\.css$/, loader: 'raw-loader' },
|
|
||||||
{ test: /\.html$/, loader: 'raw-loader' },
|
|
||||||
{ test: /\.ts$/, loader: '@ngtools/webpack' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
// #docregion
|
|
||||||
const ngtools = require('@ngtools/webpack');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
devtool: 'source-map',
|
|
||||||
entry: {
|
|
||||||
main: [
|
|
||||||
'./src/universal/app-server.module.ts',
|
|
||||||
'./src/universal/server.ts'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['.ts', '.js']
|
|
||||||
},
|
|
||||||
target: 'node',
|
|
||||||
output: {
|
|
||||||
path: __dirname + '/dist',
|
|
||||||
filename: 'server.js'
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
// compile with AOT
|
|
||||||
new ngtools.AotPlugin({
|
|
||||||
tsConfigPath: './tsconfig.universal.json'
|
|
||||||
}),
|
|
||||||
|
|
||||||
// copy assets to the output (/dist) folder
|
|
||||||
new CopyWebpackPlugin([
|
|
||||||
{from: 'src/index-universal.html'},
|
|
||||||
{from: 'src/styles.css'},
|
|
||||||
{from: 'node_modules/core-js/client/shim.min.js'},
|
|
||||||
{from: 'node_modules/zone.js/dist/zone.min.js'},
|
|
||||||
])
|
|
||||||
],
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{ test: /\.css$/, loader: 'raw-loader' },
|
|
||||||
{ test: /\.html$/, loader: 'raw-loader' },
|
|
||||||
{ test: /\.ts$/, loader: '@ngtools/webpack' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
const path = require('path');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: { server: './server.ts' },
|
||||||
|
resolve: { extensions: ['.js', '.ts'] },
|
||||||
|
target: 'node',
|
||||||
|
// this makes sure we include node_modules and other 3rd party libraries
|
||||||
|
externals: [/(node_modules|main\..*\.js)/],
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, 'dist'),
|
||||||
|
filename: '[name].js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [{ test: /\.ts$/, loader: 'ts-loader' }]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
// Temporary Fix for issue: https://github.com/angular/angular/issues/11580
|
||||||
|
// for 'WARNING Critical dependency: the request of a dependency is an expression'
|
||||||
|
new webpack.ContextReplacementPlugin(
|
||||||
|
/(.+)?angular(\\|\/)core(.+)?/,
|
||||||
|
path.join(__dirname, 'src'), // location of your src
|
||||||
|
{} // a map of your routes
|
||||||
|
),
|
||||||
|
new webpack.ContextReplacementPlugin(
|
||||||
|
/(.+)?express(\\|\/)(.+)?/,
|
||||||
|
path.join(__dirname, 'src'),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
|
@ -2,14 +2,6 @@
|
||||||
|
|
||||||
This guide describes **Angular Universal**, a technology that runs your Angular application on the server.
|
This guide describes **Angular Universal**, a technology that runs your Angular application on the server.
|
||||||
|
|
||||||
<div class="alert is-important">
|
|
||||||
|
|
||||||
This is a **preview guide**.
|
|
||||||
The Angular CLI is adding support for universal apps and
|
|
||||||
we will realign this guide with the CLI as soon as possible.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
A normal Angular application executes in the _browser_, rendering pages in the DOM in response to user actions.
|
A normal Angular application executes in the _browser_, rendering pages in the DOM in response to user actions.
|
||||||
|
|
||||||
**Angular Universal** generates _static_ application pages on the _server_
|
**Angular Universal** generates _static_ application pages on the _server_
|
||||||
|
@ -19,7 +11,7 @@ It can generate and serve those pages in response to requests from browsers.
|
||||||
It can also pre-generate pages as HTML files that you serve later.
|
It can also pre-generate pages as HTML files that you serve later.
|
||||||
|
|
||||||
This guide describes a Universal sample application that launches quickly as a server-rendered page.
|
This guide describes a Universal sample application that launches quickly as a server-rendered page.
|
||||||
Meanwhile, the browser downloads the full client version and switches to it automatically after the code loads.
|
Meanwhile, the browser downloads the full client version and switches to it automatically after the code loads.
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
|
@ -35,21 +27,21 @@ which runs in a [node express](https://expressjs.com/) server.
|
||||||
There are three main reasons to create a Universal version of your app.
|
There are three main reasons to create a Universal version of your app.
|
||||||
|
|
||||||
1. Facilitate web crawlers (SEO)
|
1. Facilitate web crawlers (SEO)
|
||||||
1. Improve performance on mobile and low-powered devices
|
1. Improve performance on mobile and low-powered devices
|
||||||
1. Show the first page quickly
|
1. Show the first page quickly
|
||||||
|
|
||||||
{@a seo}
|
{@a seo}
|
||||||
{@a web-crawlers}
|
{@a web-crawlers}
|
||||||
#### Facilitate web crawlers
|
#### Facilitate web crawlers
|
||||||
|
|
||||||
Google, Bing, Facebook, Twitter and other social media sites rely on web crawlers to index your application content and make that content searchable on the web.
|
Google, Bing, Facebook, Twitter and other social media sites rely on web crawlers to index your application content and make that content searchable on the web.
|
||||||
|
|
||||||
These web crawlers may be unable to navigate and index your highly-interactive, Angular application as a human user could do.
|
These web crawlers may be unable to navigate and index your highly-interactive, Angular application as a human user could do.
|
||||||
|
|
||||||
Angular Universal can generate a static version of your app that is easy searchable, linkable, and navigable without JavaScript.
|
Angular Universal can generate a static version of your app that is easily searchable, linkable, and navigable without JavaScript.
|
||||||
It also makes a site preview available since each URL returns a fully-rendered page.
|
It also makes a site preview available since each URL returns a fully-rendered page.
|
||||||
|
|
||||||
Enabling web crawlers is often referred to as
|
Enabling web crawlers is often referred to as
|
||||||
[Search Engine Optimization (SEO)](https://static.googleusercontent.com/media/www.google.com/en//webmasters/docs/search-engine-optimization-starter-guide.pdf).
|
[Search Engine Optimization (SEO)](https://static.googleusercontent.com/media/www.google.com/en//webmasters/docs/search-engine-optimization-starter-guide.pdf).
|
||||||
|
|
||||||
{@a no-javascript}
|
{@a no-javascript}
|
||||||
|
@ -58,14 +50,14 @@ Enabling web crawlers is often referred to as
|
||||||
|
|
||||||
Some devices don't support JavaScript or execute JavaScript so poorly that the user experience is unacceptable.
|
Some devices don't support JavaScript or execute JavaScript so poorly that the user experience is unacceptable.
|
||||||
For these cases, you may require a server-rendered, no-JavaScript version of the app.
|
For these cases, you may require a server-rendered, no-JavaScript version of the app.
|
||||||
This version, however limited, may be the only practical alternative for
|
This version, however limited, may be the only practical alternative for
|
||||||
people who otherwise would not be able to use the app at all.
|
people who otherwise would not be able to use the app at all.
|
||||||
|
|
||||||
{@a startup-performance}
|
{@a startup-performance}
|
||||||
|
|
||||||
#### Show the first page quickly
|
#### Show the first page quickly
|
||||||
|
|
||||||
Displaying the first page quickly can be critical for user engagement.
|
Displaying the first page quickly can be critical for user engagement.
|
||||||
|
|
||||||
[53% of mobile site visits are abandoned](https://www.doubleclickbygoogle.com/articles/mobile-speed-matters/) if pages take longer than 3 seconds to load.
|
[53% of mobile site visits are abandoned](https://www.doubleclickbygoogle.com/articles/mobile-speed-matters/) if pages take longer than 3 seconds to load.
|
||||||
Your app may have to launch faster to engage these users before they decide to do something else.
|
Your app may have to launch faster to engage these users before they decide to do something else.
|
||||||
|
@ -89,10 +81,10 @@ You compile the client application with the `platform-server` module instead of
|
||||||
and run the resulting Universal app on a web server.
|
and run the resulting Universal app on a web server.
|
||||||
|
|
||||||
The server (a [Node Express](https://expressjs.com/) server in _this_ guide's example)
|
The server (a [Node Express](https://expressjs.com/) server in _this_ guide's example)
|
||||||
passes client requests for application pages to Universal's `renderModuleFactory` function.
|
passes client requests for application pages to Universal's `renderModuleFactory` function.
|
||||||
|
|
||||||
The `renderModuleFactory` function takes as inputs a *template* HTML page (usually `index.html`),
|
The `renderModuleFactory` function takes as inputs a *template* HTML page (usually `index.html`),
|
||||||
an Angular *module* containing components,
|
an Angular *module* containing components,
|
||||||
and a *route* that determines which components to display.
|
and a *route* that determines which components to display.
|
||||||
|
|
||||||
The route comes from the client's request to the server.
|
The route comes from the client's request to the server.
|
||||||
|
@ -109,11 +101,11 @@ Because a Universal `platform-server` app doesn't execute in the browser, you ma
|
||||||
You won't be able reference browser-only native objects such as `window`, `document`, `navigator` or `location`.
|
You won't be able reference browser-only native objects such as `window`, `document`, `navigator` or `location`.
|
||||||
If you don't need them on the server-rendered page, side-step them with conditional logic.
|
If you don't need them on the server-rendered page, side-step them with conditional logic.
|
||||||
|
|
||||||
Alternatively, look for an injectable Angular abstraction over the object you need such as `Location` or `Document`;
|
Alternatively, look for an injectable Angular abstraction over the object you need such as `Location` or `Document`;
|
||||||
it may substitute adequately for the specific API that you're calling.
|
it may substitute adequately for the specific API that you're calling.
|
||||||
If Angular doesn't provide it, you may be able to write your own abstraction that delegates to the browser API while in the browser and to a satisfactory alternative implementation while on the server.
|
If Angular doesn't provide it, you may be able to write your own abstraction that delegates to the browser API while in the browser and to a satisfactory alternative implementation while on the server.
|
||||||
|
|
||||||
Without mouse or keyboard events, a universal app can't rely on a user clicking a button to show a component.
|
Without mouse or keyboard events, a universal app can't rely on a user clicking a button to show a component.
|
||||||
A universal app should determine what to render based solely on the incoming client request.
|
A universal app should determine what to render based solely on the incoming client request.
|
||||||
This is a good argument for making the app [routeable](guide/router).
|
This is a good argument for making the app [routeable](guide/router).
|
||||||
|
|
||||||
|
@ -129,45 +121,35 @@ The _Tour of Heroes_ tutorial is the foundation for the Universal sample describ
|
||||||
The core application files are mostly untouched, with a few exceptions described below.
|
The core application files are mostly untouched, with a few exceptions described below.
|
||||||
You'll add more files to support building and serving with Universal.
|
You'll add more files to support building and serving with Universal.
|
||||||
|
|
||||||
In this example, Webpack tools compile and bundle the Universal version of the app with the
|
In this example, the Angular CLI compiles and bundles the Universal version of the app with the
|
||||||
[AOT (Ahead-of-Time) compiler](guide/aot-compiler).
|
[AOT (Ahead-of-Time) compiler](guide/aot-compiler).
|
||||||
A node/express web server turns client requests into the HTML pages rendered by Universal.
|
A node/express web server turns client requests into the HTML pages rendered by Universal.
|
||||||
|
|
||||||
You will create:
|
You will create:
|
||||||
|
|
||||||
* a server-side app module, `app.server.module.ts`
|
* a server-side app module, `app.server.module.ts`
|
||||||
* a Universal app renderer, `universal-engine.ts`
|
* an entry point for the server-side, `main.server.ts`
|
||||||
* an express web server to handle requests, `server.ts`
|
* an express web server to handle requests, `server.ts`
|
||||||
* a TypeScript config file, `tsconfig.universal.json`
|
* a TypeScript config file, `tsconfig.server.json`
|
||||||
* a Webpack config file, `webpack.config.universal.js`
|
* a Webpack config file for the server, `webpack.server.config.js`
|
||||||
|
|
||||||
When you're done, the folder structure will look like this:
|
When you're done, the folder structure will look like this:
|
||||||
|
|
||||||
<code-example format="." language="none" linenums="false">
|
<code-example format="." language="none" linenums="false">
|
||||||
src/
|
src/
|
||||||
index.html <i>app web page</i>
|
index.html <i>app web page</i>
|
||||||
index-universal.html <i>* universal app web page template</i>
|
|
||||||
main.ts <i>bootstrapper for client app</i>
|
main.ts <i>bootstrapper for client app</i>
|
||||||
|
main.server.ts <i>* bootstrapper for server app</i>
|
||||||
|
tsconfig.app.json <i>TypeScript client configuration</i>
|
||||||
|
tsconfig.server.json <i>* TypeScript server configuration</i>
|
||||||
|
tsconfig.spec.json <i>TypeScript spec configuration</i>
|
||||||
style.css <i>styles for the app</i>
|
style.css <i>styles for the app</i>
|
||||||
systemjs.config.js <i>SystemJS client configuration</i>
|
|
||||||
systemjs-angular-loader.js <i>SystemJS add-in</i>
|
|
||||||
tsconfig.json <i>TypeScript client configuration</i>
|
|
||||||
app/ ... <i>application code</i>
|
app/ ... <i>application code</i>
|
||||||
dist/ <i>* Post-build files</i>
|
app.server.module.ts <i>* server-side application module</i>
|
||||||
client.js <i>* AOT-compiled client bundle</i>
|
server.ts <i>* express web server</i>
|
||||||
server.js <i>* express server & universal app bundle</i>
|
tsconfig.json <i>TypeScript client configuration</i>
|
||||||
index-universal.html <i>* copy of the app web page template</i>
|
|
||||||
... <i>* copies of other asset files</i>
|
|
||||||
universal/ <i>* folder for universal code</i>
|
|
||||||
app-server.module.ts <i>* server-side application module</i>
|
|
||||||
server.ts <i>* express web server</i>
|
|
||||||
universal-engine.ts <i>* express template engine</i>
|
|
||||||
bs-config.json <i>config file for lite server</i>
|
|
||||||
package.json <i>npm configuration</i>
|
package.json <i>npm configuration</i>
|
||||||
tsconfig.client.json <i>* TypeScript client AOT configuration</i>
|
webpack.server.config.js <i>* Webpack server configuration</i>
|
||||||
tsconfig.universal.json <i>* TypeScript Universal configuration</i>
|
|
||||||
webpack.config.aot.js <i>* Webpack client AOT configuration</i>
|
|
||||||
webpack.config.universal.js <i>* Webpack Universal configuration</i>
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
The files marked with `*` are new and not in the original tutorial sample.
|
The files marked with `*` are new and not in the original tutorial sample.
|
||||||
|
@ -177,26 +159,23 @@ This guide covers them in the sections below.
|
||||||
|
|
||||||
## Preparation
|
## Preparation
|
||||||
|
|
||||||
|
Download the [Tour of Heroes](generated/zips/toh-pt6/toh-pt6.zip) project and install the dependencies from it.
|
||||||
|
|
||||||
{@a install-the-tools}
|
{@a install-the-tools}
|
||||||
|
|
||||||
### Install the tools
|
### Install the tools
|
||||||
|
|
||||||
To get started, install these Universal and Webpack packages.
|
To get started, install these packages.
|
||||||
|
|
||||||
* `@angular/compiler-cli` - contains the AOT compiler.
|
|
||||||
* `@angular/platform-server` - Universal server-side components.
|
* `@angular/platform-server` - Universal server-side components.
|
||||||
* `webpack` - Webpack JavaScript bundler.
|
* `@nguniversal/module-map-ngfactory-loader` - For handling lazy-loading in the context of a server-render.
|
||||||
* `@ngtools/webpack` - Webpack loader and plugin for bundling compiled applications.
|
* `@nguniversal/express-engine` - An express engine for Universal applications.
|
||||||
* `copy-webpack-plugin` - Webpack plugin to copy asset files to the output folder.
|
* `ts-loader` - To transpile the server application
|
||||||
* `raw-loader` - Webpack loader for text files.
|
|
||||||
* `express` - node web server.
|
|
||||||
* `@types/express` - TypeScript type definitions for express.
|
|
||||||
|
|
||||||
Install them with the following commands:
|
Install them with the following commands:
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
<code-example format="." language="bash">
|
||||||
npm install @angular/compiler-cli @angular/platform-server express --save
|
npm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader @nguniversal/express-engine
|
||||||
npm install webpack @ngtools/webpack copy-webpack-plugin raw-loader @types/express --save-dev
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
{@a transition}
|
{@a transition}
|
||||||
|
@ -219,7 +198,7 @@ Replace that import with this one:
|
||||||
<code-example path="universal/src/app/app.module.ts" region="browsermodule" title="src/app/app.module.ts (withServerTransition)">
|
<code-example path="universal/src/app/app.module.ts" region="browsermodule" title="src/app/app.module.ts (withServerTransition)">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
Angular adds the `appId` value (which can be _any_ string) to the style-names of the server-rendered pages,
|
Angular adds the `appId` value (which can be _any_ string) to the style-names of the server-rendered pages,
|
||||||
so that they can be identified and removed when the client app starts.
|
so that they can be identified and removed when the client app starts.
|
||||||
|
|
||||||
You can get runtime information about the current platform and the `appId` by injection.
|
You can get runtime information about the current platform and the `appId` by injection.
|
||||||
|
@ -234,7 +213,7 @@ You can get runtime information about the current platform and the `appId` by in
|
||||||
The tutorial's `HeroService` and `HeroSearchService` delegate to the Angular `Http` module to fetch application data.
|
The tutorial's `HeroService` and `HeroSearchService` delegate to the Angular `Http` module to fetch application data.
|
||||||
These services send requests to _relative_ URLs such as `api/heroes`.
|
These services send requests to _relative_ URLs such as `api/heroes`.
|
||||||
|
|
||||||
In a Universal app, `Http` URLs must be _absolute_ (e.g., `https://my-server.com/api/heroes`)
|
In a Universal app, `Http` URLs must be _absolute_ (e.g., `https://my-server.com/api/heroes`)
|
||||||
even when the Universal web server is capable of handling those requests.
|
even when the Universal web server is capable of handling those requests.
|
||||||
|
|
||||||
You'll have to change the services to make requests with absolute URLs when running on the server
|
You'll have to change the services to make requests with absolute URLs when running on the server
|
||||||
|
@ -254,55 +233,17 @@ You don't provide `APP_BASE_HREF` in the browser version, so the `heroesUrl` rem
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
You can ignore `APP_BASE_HREF` in the browser if you've specified `<base href="/">` in the `index.html`
|
You can ignore `APP_BASE_HREF` in the browser if you've specified `<base href="/">` in the `index.html`
|
||||||
to satisfy the router's need for a base address, as the tutorial sample does.
|
to satisfy the router's need for a base address, as the tutorial sample does.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
You will provide the `APP_BASE_HREF` in the universal version of the app (see how [below](#provide-origin)),
|
|
||||||
so the `heroesUrl` becomes absolute.
|
|
||||||
|
|
||||||
Do the same thing to the `HttpSearchService` constructor.
|
|
||||||
You'll have to adjust the `http.get` call in the `search()` method as well.
|
|
||||||
Here's the revised class.
|
|
||||||
|
|
||||||
<code-example path="universal/src/app/hero-search.service.ts" region="class" title="src/app/hero-search.service.ts (with injected origin)" linenums="false">
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
{@a build-client-app}
|
|
||||||
|
|
||||||
#### Try locally first
|
|
||||||
|
|
||||||
Open a terminal window and confirm that the client app still works in the browser.
|
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
|
||||||
npm start
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
When you are done, shut down the server with `ctrl-C`.
|
|
||||||
|
|
||||||
<div class="alert is-important">
|
|
||||||
|
|
||||||
If you get a "Cannot find module" error, see the explanation and resolution [below](#cannot-find-module)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
{@a server-code}
|
{@a server-code}
|
||||||
|
|
||||||
## Server code
|
## Server code
|
||||||
|
|
||||||
To run an Angular Universal application, you'll need a server that accepts client requests and returns rendered pages.
|
To run an Angular Universal application, you'll need a server that accepts client requests and returns rendered pages.
|
||||||
|
|
||||||
Create a `universal/` folder as a sibling to the `app/` folder.
|
|
||||||
|
|
||||||
Add to it the following three universal parts:
|
|
||||||
|
|
||||||
1. the [app server module](#app-server-module)
|
|
||||||
2. the [Universal engine](#universal-engine)
|
|
||||||
3. the [web server](#web-server)
|
|
||||||
|
|
||||||
{@a app-server-module}
|
{@a app-server-module}
|
||||||
|
|
||||||
### App server module
|
### App server module
|
||||||
|
@ -310,126 +251,90 @@ Add to it the following three universal parts:
|
||||||
The app server module class (conventionally named `AppServerModule`) is an Angular module that wraps the application's root module (`AppModule`) so that Universal can mediate between your application and the server.
|
The app server module class (conventionally named `AppServerModule`) is an Angular module that wraps the application's root module (`AppModule`) so that Universal can mediate between your application and the server.
|
||||||
`AppServerModule` also tells Angular how to bootstrap your application when running as a Universal app.
|
`AppServerModule` also tells Angular how to bootstrap your application when running as a Universal app.
|
||||||
|
|
||||||
Create an `app-server.module.ts` file in the `src/universal` directory with the following `AppServerModule` code:
|
Create an `app.server.module.ts` file in the `src/app/` directory with the following `AppServerModule` code:
|
||||||
|
|
||||||
<code-example path="universal/src/universal/app-server.module.ts" title="src/universal/app-server.module.ts">
|
<code-example path="universal/src/app/app.server.module.ts" title="src/app/app.server.module.ts">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
Notice that it imports first the client app's `AppModule` and then Angular Universal's `ServerModule`.
|
Notice that it imports first the client app's `AppModule`, the Angular Universal's `ServerModule` and the `ModuleMapLoaderModule`.
|
||||||
|
|
||||||
|
The `ModuleMapLoaderModule` is a server-side module that allows lazy-loading of routes.
|
||||||
|
|
||||||
This is also the place to register providers that are specific to running your app under Universal.
|
This is also the place to register providers that are specific to running your app under Universal.
|
||||||
|
|
||||||
{@a universal-engine}
|
|
||||||
|
|
||||||
### Universal template engine
|
|
||||||
|
|
||||||
The Universal `renderModuleFactory` function turns a client's requests into server-rendered HTML pages.
|
|
||||||
You'll call that function within a _template engine_ that's appropriate for your server stack.
|
|
||||||
|
|
||||||
This guide's sample is written for [Node Express](https://expressjs.com/)
|
|
||||||
so the engine takes the form of [Express template engine middleware](https://expressjs.com/en/guide/using-template-engines.html).
|
|
||||||
|
|
||||||
Create a `universal-engine.ts` file in the `src/universal` directory with the following code.
|
|
||||||
|
|
||||||
<code-example path="universal/src/universal/universal-engine.ts" title="src/universal/universal-engine.ts">
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
{@a render-module-factory}
|
|
||||||
|
|
||||||
#### Rendering the page
|
|
||||||
The call to Universal's `renderModuleFactory` is where the rendering magic happens.
|
|
||||||
|
|
||||||
<code-example path="universal/src/universal/universal-engine.ts" title="src/universal/universal-engine.ts (rendering)" region="render">
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
The first parameter is the `AppServerModule` that you wrote [earlier](#app-server-module).
|
|
||||||
It's the bridge between the Universal server-side renderer and your application.
|
|
||||||
|
|
||||||
The second parameter is an options object
|
|
||||||
|
|
||||||
* `document` is the template for the page to render (typically `index.html`).
|
|
||||||
|
|
||||||
|
|
||||||
* `url` is the application route (e.g., `/dashboard`), extracted from the client's request.
|
|
||||||
Universal should render the appropriate page for that route.
|
|
||||||
|
|
||||||
|
|
||||||
* `extraProviders` are optional Angular dependency injection providers, applicable when running on this server
|
|
||||||
|
|
||||||
{@a provide-origin}
|
|
||||||
|
|
||||||
You supply `extraProviders` when your app needs information that can only be determined by the currently running server instance.
|
|
||||||
|
|
||||||
The required information in this case is the running server's origin, provided under the `APP_BASE_HREF` token, so that the app can [calculate absolute HTTP URLs](#http-urls).
|
|
||||||
|
|
||||||
The `renderModuleFactory` function returns a _promise_ that resolves to the rendered page.
|
|
||||||
|
|
||||||
It's up to your engine to decide what to do with that page.
|
|
||||||
_This engine's_ promise callback returns the rendered page to the [web server](#web-server),
|
|
||||||
which then forwards it to the client in the HTTP response.
|
|
||||||
|
|
||||||
{@a web-server}
|
{@a web-server}
|
||||||
|
|
||||||
### Universal web server
|
### Universal web server
|
||||||
|
|
||||||
A _Universal_ web server responds to application _page_ requests with static HTML rendered by the [Universal template engine](#universal-engine).
|
A _Universal_ web server responds to application _page_ requests with static HTML rendered by the [Universal template engine](#universal-engine).
|
||||||
|
|
||||||
It receives and responds to HTTP requests from clients (usually browsers).
|
It receives and responds to HTTP requests from clients (usually browsers).
|
||||||
It serves static assets such as scripts, css, and images.
|
It serves static assets such as scripts, css, and images.
|
||||||
It may respond to data requests, perhaps directly or as a proxy to a separate data server.
|
It may respond to data requests, perhaps directly or as a proxy to a separate data server.
|
||||||
|
|
||||||
The sample web server for _this_ guide is based on the popular [Express](https://expressjs.com/) framework.
|
The sample web server for _this_ guide is based on the popular [Express](https://expressjs.com/) framework.
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
_Any_ web server technology can serve a Universal app as long as it can call Universal's `renderModuleFactory`.
|
_Any_ web server technology can serve a Universal app as long as it can call Universal's `renderModuleFactory`.
|
||||||
The principles and decision points discussed below apply to any web server technology that you chose.
|
The principles and decision points discussed below apply to any web server technology that you chose.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Create a `server.ts` file in the `src/universal` directory and add the following code:
|
Create a `server.ts` file in the root directory and add the following code:
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts">
|
<code-example path="universal/server.ts" title="server.ts">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
<div class="alert is-critical">
|
<div class="alert is-critical">
|
||||||
|
|
||||||
**This sample server is not secure!**
|
**This sample server is not secure!**
|
||||||
Be sure to add middleware to authenticate and authorize users
|
Be sure to add middleware to authenticate and authorize users
|
||||||
just as you would for a normal Angular application server.
|
just as you would for a normal Angular application server.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{@a import-app-server-module-factory}
|
{@a universal-engine}
|
||||||
|
#### Universal template engine
|
||||||
|
|
||||||
#### Import AppServerModule factory
|
The important bit in this file is the `ngExpressEngine` function:
|
||||||
|
|
||||||
Most of this server code is re-usable across many applications.
|
<code-example path="universal/server.ts" title="server.ts" region="ngExpressEngine">
|
||||||
The import of the `AppServerModule` couples it specifically to a single application.
|
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts" region="import-app-server-factory">
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
Your code editor may tell you that this import is incorrect.
|
The `ngExpressEngine` is a wrapper around the universal's `renderModuleFactory` function that turns a client's requests into server-rendered HTML pages.
|
||||||
It refers to the source file for the `AppServerModule` factory which doesn't exist at design time.
|
You'll call that function within a _template engine_ that's appropriate for your server stack.
|
||||||
|
|
||||||
That file _will exist_, briefly, during compilation. But it's never physically in the file system when you're editing `server.ts` and you must tell the compiler to generate this module factory file _before_ it compiles `server.ts`.
|
The first parameter is the `AppServerModule` that you wrote [earlier](#app-server-module).
|
||||||
[Learn how below](#universal-typescript-configuration).
|
It's the bridge between the Universal server-side renderer and your application.
|
||||||
|
|
||||||
#### Add the Universal template engine
|
The second parameter is the `extraProviders`. It is an optional Angular dependency injection providers, applicable when running on this server.
|
||||||
|
|
||||||
Express supports template engines such as the [Universal template engine](#universal-engine) you wrote earlier.
|
{@a provide-origin}
|
||||||
You import that engine and register it with Express like this:
|
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts (Universal template engine)" region="universal-engine">
|
You supply `extraProviders` when your app needs information that can only be determined by the currently running server instance.
|
||||||
</code-example>
|
|
||||||
|
The required information in this case is the running server's origin, provided under the `APP_BASE_HREF` token, so that the app can [calculate absolute HTTP URLs](#http-urls).
|
||||||
|
|
||||||
|
The `ngExpressEngine` function returns a _promise_ that resolves to the rendered page.
|
||||||
|
|
||||||
|
It's up to your engine to decide what to do with that page.
|
||||||
|
_This engine's_ promise callback returns the rendered page to the [web server](#web-server),
|
||||||
|
which then forwards it to the client in the HTTP response.
|
||||||
|
|
||||||
|
<div class="l-sub-section">
|
||||||
|
|
||||||
|
This wrappers are very useful to hide the complexity of the `renderModuleFactory`. There are more wrappers for different backend technologies
|
||||||
|
at the [Universal repository](https://github.com/angular/universal).
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
#### Filter request URLs
|
#### Filter request URLs
|
||||||
|
|
||||||
The web server must distinguish _app page requests_ from other kinds of requests.
|
The web server must distinguish _app page requests_ from other kinds of requests.
|
||||||
|
|
||||||
It's not as simple as intercepting a request to the root address `/`.
|
It's not as simple as intercepting a request to the root address `/`.
|
||||||
The browser could ask for one of the application routes such as `/dashboard`, `/heroes`, or `/detail:12`.
|
The browser could ask for one of the application routes such as `/dashboard`, `/heroes`, or `/detail:12`.
|
||||||
In fact, if the app were _only_ rendered by the server, _every_ app link clicked would arrive at the server
|
In fact, if the app were _only_ rendered by the server, _every_ app link clicked would arrive at the server
|
||||||
as a navigation URL intended for the router.
|
as a navigation URL intended for the router.
|
||||||
|
|
||||||
|
@ -447,9 +352,9 @@ So we can easily recognize the three types of requests and handle them different
|
||||||
|
|
||||||
An Express server is a pipeline of middleware that filters and processes URL requests one after the other.
|
An Express server is a pipeline of middleware that filters and processes URL requests one after the other.
|
||||||
|
|
||||||
You configure the Express server pipeline with calls to `server.get()` like this one for data requests.
|
You configure the Express server pipeline with calls to `app.get()` like this one for data requests.
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts (data URL)" region="data-request" linenums="false">
|
<code-example path="universal/server.ts" title="server.ts (data URL)" region="data-request" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
@ -478,12 +383,12 @@ If your server handles HTTP requests, you'll have to add your own security plumb
|
||||||
|
|
||||||
The following code filters for request URLs with no extensions and treats them as navigation requests.
|
The following code filters for request URLs with no extensions and treats them as navigation requests.
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts (navigation)" region="navigation-request" linenums="false">
|
<code-example path="universal/server.ts" title="server.ts (navigation)" region="navigation-request" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
#### Serve static files safely
|
#### Serve static files safely
|
||||||
|
|
||||||
A single `server.use()` treats all other URLs as requests for static assets
|
A single `app.use()` treats all other URLs as requests for static assets
|
||||||
such as JavaScript, image, and style files.
|
such as JavaScript, image, and style files.
|
||||||
|
|
||||||
To ensure that clients can only download the files that they are _permitted_ to see, you will [put all client-facing asset files in the `/dist` folder](#universal-webpack-configuration)
|
To ensure that clients can only download the files that they are _permitted_ to see, you will [put all client-facing asset files in the `/dist` folder](#universal-webpack-configuration)
|
||||||
|
@ -491,118 +396,42 @@ and will only honor requests for files from the `/dist` folder.
|
||||||
|
|
||||||
The following express code routes all remaining requests to `/dist`; it returns a `404 - NOT FOUND` if the file is not found.
|
The following express code routes all remaining requests to `/dist`; it returns a `404 - NOT FOUND` if the file is not found.
|
||||||
|
|
||||||
<code-example path="universal/src/universal/server.ts" title="src/universal/server.ts (static files)" region="static" linenums="false">
|
<code-example path="universal/server.ts" title="server.ts (static files)" region="static" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
{@a universal-configuration}
|
{@a universal-configuration}
|
||||||
|
|
||||||
## Configure for Universal
|
## Configure for Universal
|
||||||
|
|
||||||
The server application requires its own web page and its own build configuration.
|
The server application requires its own build configuration.
|
||||||
|
|
||||||
{@a index-universal}
|
|
||||||
|
|
||||||
### Universal web page
|
|
||||||
|
|
||||||
The universal app renders pages based on a host web page template.
|
|
||||||
Simple universal apps make do with a slightly modified copy of the original `index.html`.
|
|
||||||
|
|
||||||
<div class="alert is-helpful">
|
|
||||||
|
|
||||||
If you build a production version of the client app with the CLI's `ng build --prod` command, you do not need a separate universal `index.html`.
|
|
||||||
The CLI constructs a suitable `index.html` for you. You can skip this subsection and continue to [universal TypeScript configuration](#universal-typescript-configuration).
|
|
||||||
|
|
||||||
Read on if you're building the app without the CLI.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Create an `index-universal.html` as follows, shown next to the development `index.html` for comparison.
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="src/index-universal.html" path="universal/src/index-universal.html">
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="src/index.html" path="universal/src/index.html">
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
The differences are few.
|
|
||||||
|
|
||||||
* Load the minified versions of the `shim` and `zone` polyfills from the root (which will be `/dist`)
|
|
||||||
|
|
||||||
* You won't use SystemJS for universal nor to load the client app.
|
|
||||||
|
|
||||||
* Instead you'll load the [production version of the client app](#build-client), `client.js`, which is the result of AOT compilation, minification, and bundling.
|
|
||||||
|
|
||||||
That's it for `index-universal.html`.
|
|
||||||
Next you'll create two universal configuration files, one for TypeScript and one for Webpack.
|
|
||||||
|
|
||||||
{@a universal-typescript-configuration}
|
{@a universal-typescript-configuration}
|
||||||
|
|
||||||
### Universal TypeScript configuration
|
### Universal TypeScript configuration
|
||||||
|
|
||||||
Create a `tsconfig.universal.json` file in the project root directory to configure TypeScript and AOT compilation of the universal app.
|
Create a `tsconfig.server.json` file in the project root directory to configure TypeScript and AOT compilation of the universal app.
|
||||||
|
|
||||||
<code-example path="universal/tsconfig.universal.json" title="tsconfig.universal.json">
|
<code-example path="universal/src/tsconfig.server.json" title="src/tsconfig.server.json">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
Certain settings are noteworthy for their difference from the `tsconfig.json` in the `src/` folder.
|
This config extends from the root's `tsconfig.json` file. Certain settings are noteworthy for their differences.
|
||||||
|
|
||||||
* The `module` property must be **es2015** because
|
|
||||||
the transpiled JavaScript will use `import` statements instead of `require()` calls.
|
|
||||||
|
|
||||||
|
|
||||||
* Point `"typeRoots"` to `"./node_modules/@types/"`
|
|
||||||
|
|
||||||
|
|
||||||
* Set the `files` property (instead of `exclude`) to compile the `app-server.module` before the `universal-engine`,
|
|
||||||
for the reason [explained above](#import-app-server-module-factory).
|
|
||||||
|
|
||||||
|
* The `module` property must be **commonjs** which can be require()'d into our server application.
|
||||||
|
|
||||||
* The `angularCompilerOptions` section guides the AOT compiler:
|
* The `angularCompilerOptions` section guides the AOT compiler:
|
||||||
|
* `entryModule` - the root module of the server application, expressed as `path/to/file#ClassName`.
|
||||||
* `genDir` - the temporary output directory for AOT compiled code.
|
|
||||||
* `entryModule` - the root module of the client application, expressed as `path/to/file#ClassName`.
|
|
||||||
* `skipMetadataEmit` - set `true` because you don't need metadata in the bundled application.
|
|
||||||
|
|
||||||
### Universal Webpack configuration
|
### Universal Webpack configuration
|
||||||
|
|
||||||
Create a `webpack.config.universal.js` file in the project root directory with the following code.
|
Universal applications doesn't need any extra Webpack configuration, the CLI takes care of that for you,
|
||||||
|
but since the server is a typescript application, you will use Webpack to transpile it.
|
||||||
|
|
||||||
<code-example path="universal/webpack.config.universal.js" title="webpack.config.universal.js">
|
Create a `webpack.server.config.js` file in the project root directory with the following code.
|
||||||
|
|
||||||
|
<code-example path="universal/webpack.server.config.js" title="webpack.server.config.js">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
**Webpack configuration** is a rich topic beyond the scope of this guide.
|
**Webpack configuration** is a rich topic beyond the scope of this guide.
|
||||||
A few observations may clarify some of the choices.
|
|
||||||
|
|
||||||
* Webpack walks the dependency graph from the two entry points to find all necessary universal application files.
|
|
||||||
|
|
||||||
|
|
||||||
* The `@ngtools/webpack` loader loads and prepares the TypeScript files for compilation.
|
|
||||||
|
|
||||||
|
|
||||||
* The `AotPlugin` runs the AOT compiler (`ngc`) over the prepared TypeScript, guided by the `tsconfig.universal.json` you created [above](#universal-typescript-configuration).
|
|
||||||
|
|
||||||
|
|
||||||
* The `raw-loader` loads imported CSS and HTML files as strings.
|
|
||||||
You may need additional loaders or configuration for other file types.
|
|
||||||
|
|
||||||
|
|
||||||
* The compiled output is bundled into `dist/server.js`.
|
|
||||||
|
|
||||||
|
|
||||||
* The `CopyWebpackPlugin` copies specific static files from their source locations into the `/dist` folder.
|
|
||||||
These files include the universal app's web page template, `index-universal.html`,
|
|
||||||
and the JavaScript and CSS files mentioned in it
|
|
||||||
... with the notable exception of `client.js` [to be discussed below](#build-client).
|
|
||||||
|
|
||||||
<div class="alert is-helpful">
|
|
||||||
|
|
||||||
The `CopyWebpackPlugin` step is unnecessary if you [build the client](#build-client) with the CLI.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Build and run with universal
|
## Build and run with universal
|
||||||
|
|
||||||
|
@ -613,8 +442,10 @@ First add the _build_ and _serve_ commands to the `scripts` section of the `pack
|
||||||
<code-example format="." language="ts">
|
<code-example format="." language="ts">
|
||||||
"scripts": {
|
"scripts": {
|
||||||
...
|
...
|
||||||
"build:uni": "webpack --config webpack.config.universal.js",
|
"build:universal": "npm run build:client-and-server-bundles && npm run webpack:server",
|
||||||
"serve:uni": "node dist/server.js",
|
"serve:universal": "node dist/server.js",
|
||||||
|
"build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
|
||||||
|
"webpack:server": "webpack --config webpack.server.config.js --progress --colors"
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
</code-example>
|
</code-example>
|
||||||
|
@ -626,32 +457,30 @@ First add the _build_ and _serve_ commands to the `scripts` section of the `pack
|
||||||
From the command prompt, type
|
From the command prompt, type
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
<code-example format="." language="bash">
|
||||||
npm run build:uni
|
npm run build:universal
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
Webpack compiles and bundles the universal app into a single output file, `dist/server.js`, per the [configuration above](#universal-configuration).
|
The Angular CLI compiles and bundles the universal app into two different folders, `browser` and `server`.
|
||||||
It also generates a [source map](https://webpack.js.org/configuration/devtool/), `dist/server.js.map` that correlates the bundle code to the source code.
|
Webpack transpiles the `server.ts` file into Javascript.
|
||||||
|
|
||||||
Source maps are primarily for the browser's [dev tools](https://developers.google.com/web/tools/chrome-devtools/javascript/source-maps), but on the server they help locate compilation errors in your components.
|
|
||||||
|
|
||||||
{@a serve}
|
{@a serve}
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
After building the server bundle, start the server.
|
After building the application, start the server.
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
<code-example format="." language="bash">
|
||||||
npm run serve:uni
|
npm run serve:universal
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
The console window should say
|
The console window should say
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
<code-example format="." language="bash">
|
||||||
listening on port 3200...
|
Node server listening on http://localhost:4000
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
## Universal in action
|
## Universal in action
|
||||||
|
|
||||||
Open a browser to http://localhost:3200/.
|
Open a browser to http://localhost:4000/.
|
||||||
You should see the familiar Tour of Heroes dashboard page.
|
You should see the familiar Tour of Heroes dashboard page.
|
||||||
|
|
||||||
Navigation via `routerLinks` works correctly.
|
Navigation via `routerLinks` works correctly.
|
||||||
|
@ -668,150 +497,24 @@ But clicks, mouse-moves, and keyboard entries are inert.
|
||||||
User events other than `routerLink` clicks aren't supported.
|
User events other than `routerLink` clicks aren't supported.
|
||||||
The user must wait for the full client app to arrive.
|
The user must wait for the full client app to arrive.
|
||||||
|
|
||||||
It will never arrive until you compile the client app
|
It will never arrive until you compile the client app
|
||||||
and move the output into the `dist/` folder,
|
and move the output into the `dist/` folder,
|
||||||
a step you'll take in just a moment.
|
a step you'll take in just a moment.
|
||||||
|
|
||||||
#### Review the console log
|
|
||||||
|
|
||||||
Open the browser's development tools.
|
|
||||||
In the console window you should see output like the following:
|
|
||||||
|
|
||||||
<code-example format="." language="bash" linenums="false">
|
|
||||||
listening on port 3200...
|
|
||||||
Running in the browser with appId=uni
|
|
||||||
/styles.css
|
|
||||||
/shim.min.js
|
|
||||||
/zone.min.js
|
|
||||||
/client.js
|
|
||||||
Error: ENOENT: no such file or directory, stat '... dist/client.js' ...
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
Most of the console log lines report requests for static files coming from the `<link>` and `<script>` tags in the `index-universal.html`.
|
|
||||||
The `.js` files in particular are needed to run the client version of the app in the browser.
|
|
||||||
Once they're loaded, Angular _should_ replace the Universal-rendered page with the full client app.
|
|
||||||
|
|
||||||
Except that it didn't!
|
|
||||||
|
|
||||||
#### Missing _client.js_ error
|
|
||||||
|
|
||||||
Note the error at the bottom of the console log that complains about a missing `client.js` file.
|
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
|
||||||
Error: ENOENT: no such file or directory, stat '... dist/client.js' ...
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
The full client app doesn't launch because `client.js` doesn't exist.
|
|
||||||
And `client.js` doesn't exist because you have not yet built the client version of the app.
|
|
||||||
|
|
||||||
{@a build-client}
|
|
||||||
## Build the client app
|
|
||||||
|
|
||||||
The express server is sending the universal server-side rendered pages to the client.
|
|
||||||
But it isn't serving the interactive client app because you haven't built it yet.
|
|
||||||
|
|
||||||
A key motivation for universal is to quickly render the first page on the client so of course
|
|
||||||
you want to transition to the client app as quickly as possible too.
|
|
||||||
You should build a small, _production_ version of the client app with that AOT compiler that loads and runs fast.
|
|
||||||
|
|
||||||
#### Build the client with the CLI
|
|
||||||
|
|
||||||
If you're using the CLI to build the client app, you simply run the following command and you're done.
|
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
|
||||||
ng build --prod
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
The CLI takes care of the rest, including copying all necessary files to the `/dist` folder.
|
|
||||||
By default the CLI produces two separate client app bundles, one with the vendor packages (`vendor.bundle.js`) and one with your application code (`inline.bundle.js`).
|
|
||||||
|
|
||||||
Alternatively, you can build the client using CLI _tools_ but **_without the CLI itself_**.
|
|
||||||
Read the following sub-sections if that interests you.
|
|
||||||
If not, skip ahead to the section on [throttling](#throttling).
|
|
||||||
|
|
||||||
#### Build the client by hand
|
|
||||||
|
|
||||||
You can build the application without the considerable help of the CLI.
|
|
||||||
You'll still compile with AOT.
|
|
||||||
You'll still bundle and minify with Webpack.
|
|
||||||
|
|
||||||
You'll need two configuration files, just as you did for the universal server: one for TypeScript and one for Webpack.
|
|
||||||
|
|
||||||
The client app versions are only slightly different from the corresponding server files.
|
|
||||||
Here they are, followed by notes that call out the differences:
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="tsconfig.client.json" path="universal/tsconfig.client.json">
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="webpack.config.client.js" path="universal/webpack.config.client.js">
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
The **_tsconfig.client.json_** inherits (via `extends`) most settings from the universal `tsconfig`. The _only_ substantive difference is in the `files` section which identifies the client app bootstrapping file, `main.ts`, from which the compiler discovers all other required files.
|
|
||||||
|
|
||||||
The **_webpack.config.client.js_** has a few differences,
|
|
||||||
all of them obvious.
|
|
||||||
|
|
||||||
* There is only one `entry.main` file, `main.ts`.
|
|
||||||
|
|
||||||
* The output filename is `client.js`.
|
|
||||||
|
|
||||||
* The `AotPlugin` references the `./tsconfig.client.json`.
|
|
||||||
|
|
||||||
* There's no need to copy asset files because the [universal Webpack config](#universal-webpack-configuration)
|
|
||||||
took care of them.
|
|
||||||
|
|
||||||
* Add the `UglifyJSPlugin` to minify the client app code.
|
|
||||||
|
|
||||||
Why minify the client code and not the server code?
|
|
||||||
You minify client code to reduce the payload transmitted to the browser. The universal server code stays on the server where minification is pointless.
|
|
||||||
|
|
||||||
#### Run Webpack for the client
|
|
||||||
|
|
||||||
Add an `npm` script to make it easy to build the client from the terminal window.
|
|
||||||
<code-example format="." language="ts">
|
|
||||||
"scripts": {
|
|
||||||
...
|
|
||||||
"build:uni-client": "webpack --config webpack.config.client.js",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
</code-example>
|
|
||||||
Now run that command
|
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
|
||||||
npm run build:uni-client
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
Refresh the browser.
|
|
||||||
The console log shows that the server can find `client.js`
|
|
||||||
The Universal app is quickly replaced by the full client app.
|
|
||||||
|
|
||||||
Most importantly, the event-based features now work as expected.
|
|
||||||
|
|
||||||
<div class="alert is-critical">
|
|
||||||
|
|
||||||
When you make application changes, remember to rebuild _both_ the universal server _and_ the client versions of the app.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Throttling
|
## Throttling
|
||||||
|
|
||||||
The transition from the server-rendered app to the client app happens quickly on a development machine.
|
The transition from the server-rendered app to the client app happens quickly on a development machine.
|
||||||
You can simulate a slower network to see the transition more clearly and
|
You can simulate a slower network to see the transition more clearly and
|
||||||
better appreciate the launch-speed advantage of a universal app running on a low powered, poorly connected device.
|
better appreciate the launch-speed advantage of a universal app running on a low powered, poorly connected device.
|
||||||
|
|
||||||
Open the Chrome Dev Tools and go to the Network tab.
|
Open the Chrome Dev Tools and go to the Network tab.
|
||||||
Find the [Network Throttling](https://developers.google.com/web/tools/chrome-devtools/network-performance/reference#throttling) dropdown on the far right of the menu bar.
|
Find the [Network Throttling](https://developers.google.com/web/tools/chrome-devtools/network-performance/reference#throttling) dropdown on the far right of the menu bar.
|
||||||
|
|
||||||
Try one of the "3G" speeds.
|
Try one of the "3G" speeds.
|
||||||
The server-rendered app still launches quickly but the full client app may take seconds to load.
|
The server-rendered app still launches quickly but the full client app may take seconds to load.
|
||||||
|
|
||||||
{@a conclusion}
|
{@a summary}
|
||||||
|
## Summary
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This guide showed you how to take an existing Angular application and make it into a Universal app that does server-side rendering.
|
This guide showed you how to take an existing Angular application and make it into a Universal app that does server-side rendering.
|
||||||
It also explained some of the key reasons for doing so.
|
It also explained some of the key reasons for doing so.
|
||||||
|
@ -822,32 +525,3 @@ It also explained some of the key reasons for doing so.
|
||||||
|
|
||||||
Angular Universal can greatly improve the perceived startup performance of your app.
|
Angular Universal can greatly improve the perceived startup performance of your app.
|
||||||
The slower the network, the more advantageous it becomes to have Universal display the first page to the user.
|
The slower the network, the more advantageous it becomes to have Universal display the first page to the user.
|
||||||
|
|
||||||
|
|
||||||
{@a cannot-find-module}
|
|
||||||
|
|
||||||
#### Appendix: _Cannot find module_ error
|
|
||||||
|
|
||||||
As you continue to develop the application locally,
|
|
||||||
running the `npm start` command outside of universal, the compiler may fail with the following error:
|
|
||||||
|
|
||||||
<code-example format="." language="bash">
|
|
||||||
error TS2307: Cannot find module '../../aot/src/universal/app-server.module.ngfactory'.
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
The likely cause is that you've been through these guide steps before and now have a `/universal` folder.
|
|
||||||
That folder holds server-side artifacts that are irrelevant to the client app and are confusing the compiler.
|
|
||||||
|
|
||||||
You must exclude the _server-side_ `/universal` folder files from _client app_ compilation.
|
|
||||||
|
|
||||||
Open `tsconfig.json`, find the `"exclude"` node and add `"universal/*"` to the array.
|
|
||||||
The result might look something like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
"exclude": [
|
|
||||||
"node_modules/*",
|
|
||||||
"universal/*"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Compile and run again with `npm start`.
|
|
|
@ -1,41 +1,25 @@
|
||||||
{
|
{
|
||||||
"scripts": [
|
"scripts": [
|
||||||
{ "name": "build" },
|
{ "name": "ng", "command": "ng" },
|
||||||
{ "name": "build:watch" },
|
{ "name": "start", "command": "ng serve" },
|
||||||
{ "name": "serve" },
|
{ "name": "test", "command": "ng test" },
|
||||||
{ "name": "build:aot" },
|
{ "name": "lint", "command": "ng lint" },
|
||||||
{ "name": "serve:aot" },
|
{ "name": "e2e", "command": "ng e2e" },
|
||||||
{ "name": "build:uni" },
|
{ "name": "build:ssr", "command": "npm run build:client-and-server-bundles && npm run webpack:server" },
|
||||||
{ "name": "serve:uni" },
|
{ "name": "serve:ssr", "command": "node dist/server.js" },
|
||||||
{ "name": "copy-dist-files" },
|
{ "name": "build:client-and-server-bundles", "command": "ng build --prod && ng build --prod --app 1 --output-hashing=false" },
|
||||||
{ "name": "i18n" }
|
{ "name": "webpack:server", "command": "webpack --config webpack.server.config.js --progress --colors" }
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"systemjs",
|
"web-animations-js",
|
||||||
"@angular/compiler-cli",
|
"@nguniversal/express-engine",
|
||||||
"@angular/platform-server",
|
"@nguniversal/module-map-ngfactory-loader",
|
||||||
"express"
|
"ts-loader"
|
||||||
],
|
],
|
||||||
"devDependencies": [
|
"devDependencies": [
|
||||||
"@ngtools/webpack",
|
"@angular/cli",
|
||||||
"@types/angular",
|
"@types/jasminewd2",
|
||||||
"@types/angular-animate",
|
"karma-coverage-istanbul-reporter",
|
||||||
"@types/angular-cookies",
|
"ts-node"
|
||||||
"@types/angular-mocks",
|
|
||||||
"@types/angular-resource",
|
|
||||||
"@types/angular-route",
|
|
||||||
"@types/angular-sanitize",
|
|
||||||
"@types/express",
|
|
||||||
"canonical-path",
|
|
||||||
"http-server",
|
|
||||||
"concurrently",
|
|
||||||
"lite-server",
|
|
||||||
"raw-loader",
|
|
||||||
"rollup",
|
|
||||||
"rollup-plugin-commonjs",
|
|
||||||
"rollup-plugin-node-resolve",
|
|
||||||
"rollup-plugin-uglify",
|
|
||||||
"source-map-explorer",
|
|
||||||
"webpack"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,12 @@ BOILERPLATE_PATHS.i18n = [
|
||||||
'package.json'
|
'package.json'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
BOILERPLATE_PATHS.universal = [
|
||||||
|
...cliRelativePath,
|
||||||
|
'.angular-cli.json',
|
||||||
|
'package.json'
|
||||||
|
];
|
||||||
|
|
||||||
const EXAMPLE_CONFIG_FILENAME = 'example-config.json';
|
const EXAMPLE_CONFIG_FILENAME = 'example-config.json';
|
||||||
|
|
||||||
class ExampleBoilerPlate {
|
class ExampleBoilerPlate {
|
||||||
|
@ -110,6 +116,10 @@ class ExampleBoilerPlate {
|
||||||
|
|
||||||
copyFile(sourceFolder, destinationFolder, filePath) {
|
copyFile(sourceFolder, destinationFolder, filePath) {
|
||||||
const sourcePath = path.resolve(sourceFolder, filePath);
|
const sourcePath = path.resolve(sourceFolder, filePath);
|
||||||
|
|
||||||
|
// normalize path if needed
|
||||||
|
filePath = this.normalizePath(filePath);
|
||||||
|
|
||||||
const destinationPath = path.resolve(destinationFolder, filePath);
|
const destinationPath = path.resolve(destinationFolder, filePath);
|
||||||
fs.copySync(sourcePath, destinationPath, { overwrite: true });
|
fs.copySync(sourcePath, destinationPath, { overwrite: true });
|
||||||
fs.chmodSync(destinationPath, 444);
|
fs.chmodSync(destinationPath, 444);
|
||||||
|
@ -118,6 +128,11 @@ class ExampleBoilerPlate {
|
||||||
loadJsonFile(filePath) {
|
loadJsonFile(filePath) {
|
||||||
return fs.readJsonSync(filePath, {throws: false}) || {};
|
return fs.readJsonSync(filePath, {throws: false}) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizePath(filePath) {
|
||||||
|
// transform for example ../cli/src/tsconfig.app.json to src/tsconfig.app.json
|
||||||
|
return filePath.replace(/\.{2}\/\w+\//, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ExampleBoilerPlate();
|
module.exports = new ExampleBoilerPlate();
|
||||||
|
|
|
@ -12,6 +12,7 @@ describe('example-boilerplate tool', () => {
|
||||||
const BPFiles = {
|
const BPFiles = {
|
||||||
cli: 18,
|
cli: 18,
|
||||||
i18n: 1,
|
i18n: 1,
|
||||||
|
universal: 2,
|
||||||
systemjs: 7,
|
systemjs: 7,
|
||||||
common: 1
|
common: 1
|
||||||
};
|
};
|
||||||
|
@ -88,6 +89,21 @@ describe('example-boilerplate tool', () => {
|
||||||
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledWith(`${boilerplateDir}/common`, 'c/d', 'src/styles.css');
|
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledWith(`${boilerplateDir}/common`, 'c/d', 'src/styles.css');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should copy all the source boilerplate files for universal', () => {
|
||||||
|
const boilerplateDir = path.resolve(sharedDir, 'boilerplate');
|
||||||
|
exampleBoilerPlate.loadJsonFile.and.callFake(filePath => filePath.indexOf('a/b') !== -1 ? { projectType: 'universal' } : {})
|
||||||
|
exampleBoilerPlate.add();
|
||||||
|
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledTimes(
|
||||||
|
(BPFiles.cli + BPFiles.universal) +
|
||||||
|
(BPFiles.cli) +
|
||||||
|
(BPFiles.common * exampleFolders.length)
|
||||||
|
);
|
||||||
|
// for example
|
||||||
|
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledWith(`${boilerplateDir}/universal`, 'a/b', '../cli/tslint.json');
|
||||||
|
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledWith(`${boilerplateDir}/universal`, 'a/b', '.angular-cli.json');
|
||||||
|
expect(exampleBoilerPlate.copyFile).toHaveBeenCalledWith(`${boilerplateDir}/common`, 'c/d', 'src/styles.css');
|
||||||
|
});
|
||||||
|
|
||||||
it('should try to load the example config file', () => {
|
it('should try to load the example config file', () => {
|
||||||
exampleBoilerPlate.add();
|
exampleBoilerPlate.add();
|
||||||
expect(exampleBoilerPlate.loadJsonFile).toHaveBeenCalledTimes(exampleFolders.length);
|
expect(exampleBoilerPlate.loadJsonFile).toHaveBeenCalledTimes(exampleFolders.length);
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"project": {
|
||||||
|
"name": "angular.io-example"
|
||||||
|
},
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"root": "src",
|
||||||
|
"outDir": "dist/browser",
|
||||||
|
"assets": [
|
||||||
|
"assets",
|
||||||
|
"favicon.ico"
|
||||||
|
],
|
||||||
|
"index": "index.html",
|
||||||
|
"main": "main.ts",
|
||||||
|
"polyfills": "polyfills.ts",
|
||||||
|
"test": "test.ts",
|
||||||
|
"tsconfig": "tsconfig.app.json",
|
||||||
|
"testTsconfig": "tsconfig.spec.json",
|
||||||
|
"prefix": "app",
|
||||||
|
"styles": [
|
||||||
|
"styles.css"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"environmentSource": "environments/environment.ts",
|
||||||
|
"environments": {
|
||||||
|
"dev": "environments/environment.ts",
|
||||||
|
"prod": "environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "server",
|
||||||
|
"root": "src",
|
||||||
|
"outDir": "dist/server",
|
||||||
|
"assets": [
|
||||||
|
"assets",
|
||||||
|
"favicon.ico"
|
||||||
|
],
|
||||||
|
"index": "index.html",
|
||||||
|
"main": "main.server.ts",
|
||||||
|
"test": "test.ts",
|
||||||
|
"tsconfig": "tsconfig.server.json",
|
||||||
|
"testTsconfig": "tsconfig.spec.json",
|
||||||
|
"prefix": "app",
|
||||||
|
"styles": [
|
||||||
|
"styles.css"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"environmentSource": "environments/environment.ts",
|
||||||
|
"environments": {
|
||||||
|
"dev": "environments/environment.ts",
|
||||||
|
"prod": "environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"e2e": {
|
||||||
|
"protractor": {
|
||||||
|
"config": "./protractor.conf.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": [
|
||||||
|
{
|
||||||
|
"project": "src/tsconfig.app.json",
|
||||||
|
"exclude": "**/node_modules/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "src/tsconfig.spec.json",
|
||||||
|
"exclude": "**/node_modules/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "e2e/tsconfig.e2e.json",
|
||||||
|
"exclude": "**/node_modules/**"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"test": {
|
||||||
|
"karma": {
|
||||||
|
"config": "./karma.conf.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"styleExt": "css",
|
||||||
|
"component": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "angular.io-example",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"test": "ng test",
|
||||||
|
"lint": "ng lint",
|
||||||
|
"e2e": "ng e2e",
|
||||||
|
"build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
|
||||||
|
"serve:ssr": "node dist/server.js",
|
||||||
|
"build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
|
||||||
|
"webpack:server": "webpack --config webpack.server.config.js --progress --colors"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^5.0.0",
|
||||||
|
"@angular/common": "^5.0.0",
|
||||||
|
"@angular/compiler": "^5.0.0",
|
||||||
|
"@angular/compiler-cli": "^5.0.0",
|
||||||
|
"@angular/core": "^5.0.0",
|
||||||
|
"@angular/forms": "^5.0.0",
|
||||||
|
"@angular/http": "^5.0.0",
|
||||||
|
"@angular/platform-browser": "^5.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^5.0.0",
|
||||||
|
"@angular/platform-server": "^5.0.0",
|
||||||
|
"@angular/router": "^5.0.0",
|
||||||
|
"@nguniversal/express-engine": "^1.0.0-beta.3",
|
||||||
|
"@nguniversal/module-map-ngfactory-loader": "^1.0.0-beta.3",
|
||||||
|
"core-js": "^2.4.1",
|
||||||
|
"rxjs": "^5.4.2",
|
||||||
|
"ts-loader": "^3.1.1",
|
||||||
|
"web-animations-js": "^2.3.1",
|
||||||
|
"zone.js": "^0.8.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/cli": "1.5.0",
|
||||||
|
"@angular/compiler-cli": "^4.2.4",
|
||||||
|
"@angular/language-service": "^4.2.4",
|
||||||
|
"@types/jasmine": "~2.5.53",
|
||||||
|
"@types/jasminewd2": "~2.0.2",
|
||||||
|
"@types/node": "~6.0.60",
|
||||||
|
"codelyzer": "~3.1.1",
|
||||||
|
"jasmine-core": "~2.6.2",
|
||||||
|
"jasmine-spec-reporter": "~4.1.0",
|
||||||
|
"karma": "~1.7.0",
|
||||||
|
"karma-chrome-launcher": "~2.1.1",
|
||||||
|
"karma-cli": "~1.0.1",
|
||||||
|
"karma-coverage-istanbul-reporter": "^1.2.1",
|
||||||
|
"karma-jasmine": "~1.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "^0.2.2",
|
||||||
|
"protractor": "~5.1.2",
|
||||||
|
"ts-node": "~3.2.0",
|
||||||
|
"tslint": "~5.3.2",
|
||||||
|
"typescript": "~2.3.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,11 +25,14 @@
|
||||||
"@angular/platform-server": "~5.0.0",
|
"@angular/platform-server": "~5.0.0",
|
||||||
"@angular/router": "~5.0.0",
|
"@angular/router": "~5.0.0",
|
||||||
"@angular/upgrade": "~5.0.0",
|
"@angular/upgrade": "~5.0.0",
|
||||||
|
"@nguniversal/express-engine": "^1.0.0-beta.3",
|
||||||
|
"@nguniversal/module-map-ngfactory-loader": "^1.0.0-beta.3",
|
||||||
"angular-in-memory-web-api": "~0.5.0",
|
"angular-in-memory-web-api": "~0.5.0",
|
||||||
"core-js": "^2.4.1",
|
"core-js": "^2.4.1",
|
||||||
"express": "^4.14.1",
|
"express": "^4.14.1",
|
||||||
"rxjs": "^5.5.0",
|
"rxjs": "^5.5.0",
|
||||||
"systemjs": "0.19.39",
|
"systemjs": "0.19.39",
|
||||||
|
"ts-loader": "^3.1.1",
|
||||||
"web-animations-js": "^2.3.1",
|
"web-animations-js": "^2.3.1",
|
||||||
"zone.js": "^0.8.4"
|
"zone.js": "^0.8.4"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue