docs: add universal guide (#18707)

- based on original effort in PR 17573

PR Close #18707
This commit is contained in:
Ward Bell
2017-10-09 14:46:04 -07:00
committed by Chuck Jazdzewski
parent 963a4d0dc8
commit 0b0d25fa33
36 changed files with 1200 additions and 336 deletions
+3
View File
@@ -72,6 +72,9 @@ aot-compiler/**/*.factory.d.ts
# styleguide
!styleguide/src/systemjs.custom.js
# universal
!universal/**/webpack.config.universal.js
# plunkers
*plnkr.no-link.html
@@ -1,62 +0,0 @@
{
"name": "toh-universal",
"version": "1.0.0",
"description": "Tour-of-Heroes application with ng-universal server-side rendering",
"scripts": {
"build": "tsc -p src/",
"build:watch": "tsc -w",
"build:aot": "webpack --config webpack.config.aot.js",
"build:uni": "webpack --config webpack.config.uni.js",
"serve": "lite-server -c=bs-config.json",
"serve:aot": "lite-server -c=bs-config.aot.js",
"serve:uni": "node src/dist/server.js",
"serve:uni2": "lite-server -c bs-config.uni.js",
"prestart": "npm run build",
"start": "concurrently \"npm run build:watch\" \"npm run serve\"",
"lint": "tslint ./src/**/*.ts -t verbose",
"ngc": "ngc",
"clean": "rimraf src/dist && rimraf src/app/*.js* && rimraf src/uni/*.js* && rimraf src/main.js*"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@angular/common": "angular/common-builds",
"@angular/compiler": "angular/compiler-builds",
"@angular/compiler-cli": "angular/compiler-cli-builds",
"@angular/core": "angular/core-builds",
"@angular/forms": "angular/forms-builds",
"@angular/http": "angular/http-builds",
"@angular/platform-browser": "angular/platform-browser-builds",
"@angular/platform-browser-dynamic": "angular/platform-browser-dynamic-builds",
"@angular/platform-server": "angular/platform-server-builds",
"@angular/router": "angular/router-builds",
"angular-in-memory-web-api": "^0.3.1",
"systemjs": "0.19.40",
"core-js": "^2.4.1",
"rxjs": "5.1.1",
"zone.js": "^0.7.7"
},
"devDependencies": {
"concurrently": "^3.2.0",
"lite-server": "^2.2.2",
"typescript": "~2.1.6",
"canonical-path": "0.0.2",
"tslint": "^3.15.1",
"lodash": "^4.16.4",
"rimraf": "^2.5.4",
"@types/node": "^6.0.46",
"@ngtools/webpack": "^1.2.11",
"@types/express": "^4.0.35",
"raw-loader": "^0.5.1",
"webpack": "^2.2.1"
},
"repository": {}
}
@@ -9,7 +9,8 @@ const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
{ path: 'heroes', component: HeroesComponent }
{ path: 'heroes', component: HeroesComponent },
{ path: '**', redirectTo: '/dashboard' }
];
@NgModule({
@@ -1,4 +1,3 @@
/* #docregion */
h1 {
font-size: 1.2em;
color: #999;
@@ -1,5 +1,3 @@
// #docplaster
// #docregion
import { Component } from '@angular/core';
@Component({
@@ -1,54 +1,62 @@
// #docplaster
// #docregion
// #docregion v1, v2
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
// #docregion simple
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutingModule } from './app-routing.module';
// #enddocregion v1
// Imports for loading & configuring the in-memory web api
import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
// #docregion v1
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard.component';
import { HeroesComponent } from './heroes.component';
import { HeroDetailComponent } from './hero-detail.component';
import { HeroService } from './hero.service';
// #enddocregion v1, v2
import { HeroSearchComponent } from './hero-search.component';
// #docregion v1, v2
// #enddocregion simple
// #docregion platform-detection
import { PLATFORM_ID, APP_ID, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
// #enddocregion platform-detection
// #docregion simple
@NgModule({
imports: [
BrowserModule.withServerTransition({
appId: 'toh-universal'
}),
// #docregion browsermodule
BrowserModule.withServerTransition({ appId: 'uni' }),
// #enddocregion browsermodule
FormsModule,
HttpModule,
// #enddocregion v1
// #docregion in-mem-web-api
InMemoryWebApiModule.forRoot(InMemoryDataService),
// #enddocregion in-mem-web-api
// #docregion v1
HttpClientModule,
HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService),
AppRoutingModule
],
// #docregion search
declarations: [
AppComponent,
DashboardComponent,
HeroDetailComponent,
HeroesComponent,
// #enddocregion v1, v2
HeroSearchComponent
// #docregion v1, v2
],
// #enddocregion search
providers: [ HeroService ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
export class AppModule {
// #enddocregion simple
// #docregion platform-detection
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
@Inject(APP_ID) private appId: string) {
const platform = isPlatformBrowser(platformId) ?
'on the server' : 'in the browser';
console.log(`Running ${platform} with appId=${appId}`);
}
// #enddocregion platform-detection
// #docregion simple
}
// #enddocregion simple
@@ -1,4 +1,3 @@
/* #docregion */
[class*='col-'] {
float: left;
padding-right: 20px;
@@ -1,7 +1,6 @@
<!-- #docregion -->
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" [routerLink]="['/detail', hero.id]" class="col-1-4">
<a *ngFor="let hero of heroes | async" [routerLink]="['/detail', hero.id]" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
@@ -1,22 +1,23 @@
// #docregion , search
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' ]
})
// #enddocregion search
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
heroes: Observable<Hero[]>;
constructor(private heroService: HeroService) { }
ngOnInit(): void {
this.heroService.getHeroes()
.then(heroes => this.heroes = heroes.slice(1, 5));
this.heroes = this.heroService.getHeroes()
.map(heroes => heroes.slice(1, 5));
}
}
@@ -1,4 +1,3 @@
/* #docregion */
label {
display: inline-block;
width: 3em;
@@ -25,6 +24,6 @@ button:hover {
}
button:disabled {
background-color: #eee;
color: #ccc;
color: #ccc;
cursor: auto;
}
@@ -1,4 +1,3 @@
<!-- #docregion -->
<div *ngIf="hero">
<h2>{{hero.name}} details!</h2>
<div>
@@ -8,7 +7,5 @@
<input [(ngModel)]="hero.name" placeholder="name" />
</div>
<button (click)="goBack()">Back</button>
<!-- #docregion save -->
<button (click)="save()">Save</button>
<!-- #enddocregion save -->
</div>
@@ -1,4 +1,3 @@
// #docregion
import 'rxjs/add/operator/switchMap';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
@@ -27,12 +26,11 @@ export class HeroDetailComponent implements OnInit {
.subscribe(hero => this.hero = hero);
}
// #docregion save
save(): void {
this.heroService.update(this.hero)
.then(() => this.goBack());
this.heroService
.update(this.hero)
.subscribe(() => this.goBack());
}
// #enddocregion save
goBack(): void {
this.location.back();
@@ -1,4 +1,3 @@
/* #docregion */
.search-result{
border-bottom: 1px solid gray;
border-left: 1px solid gray;
@@ -1,4 +1,3 @@
<!-- #docregion -->
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
@@ -1,20 +1,14 @@
// #docplaster
// #docregion
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
// #docregion rxjs-imports
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
// Observable class extensions
import 'rxjs/add/observable/of';
// Observable operators
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
// #enddocregion rxjs-imports
import { HeroSearchService } from './hero-search.service';
import { Hero } from './hero';
@@ -26,27 +20,20 @@ import { Hero } from './hero';
providers: [HeroSearchService]
})
export class HeroSearchComponent implements OnInit {
// #docregion search
heroes: Observable<Hero[]>;
// #enddocregion search
// #docregion searchTerms
private searchTerms = new Subject<string>();
// #enddocregion searchTerms
constructor(
private heroSearchService: HeroSearchService,
private router: Router) {}
// #docregion searchTerms
// Push a search term into the observable stream.
search(term: string): void {
this.searchTerms.next(term);
}
// #enddocregion searchTerms
// #docregion search
ngOnInit(): void {
this.heroes = this.searchTerms
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
@@ -60,7 +47,6 @@ export class HeroSearchComponent implements OnInit {
return Observable.of<Hero[]>([]);
});
}
// #enddocregion search
gotoDetail(hero: Hero): void {
let link = ['/detail', hero.id];
@@ -1,20 +1,28 @@
// #docregion
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
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 { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import { Hero } from './hero';
import { Hero } from './hero';
// #docregion class
@Injectable()
export class HeroSearchService {
constructor(private http: Http) {}
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(`api/heroes/?name=${term}`)
.map(response => response.json().data as Hero[]);
.get(this.searchUrl + term)
.map((data: any) => data.data as Hero[]);
}
}
// #enddocregion class
@@ -1,87 +1,64 @@
// #docplaster
// #docregion , imports
import { Injectable } from '@angular/core';
import { Headers, Http } from '@angular/http';
import { Injectable, Inject, Optional } from '@angular/core';
import { APP_BASE_HREF } from '@angular/common';
import { HttpHeaders, HttpClient } from '@angular/common/http';
// #docregion rxjs
import 'rxjs/add/operator/toPromise';
// #enddocregion rxjs
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/map';
import { Hero } from './hero';
// #enddocregion imports
const headers = new HttpHeaders({'Content-Type': 'application/json'});
@Injectable()
export class HeroService {
// #docregion update
private headers = new Headers({'Content-Type': 'application/json'});
// #enddocregion update
// #docregion getHeroes
private heroesUrl = 'api/heroes'; // URL to web api
constructor(private http: Http) { }
// #docregion ctor
constructor(
private http: HttpClient,
@Optional() @Inject(APP_BASE_HREF) origin: string) {
this.heroesUrl = (origin || '') + this.heroesUrl;
}
// #enddocregion ctor
getHeroes(): Promise<Hero[]> {
getHeroes(): Observable<Hero[]> {
return this.http.get(this.heroesUrl)
// #docregion to-promise
.toPromise()
// #enddocregion to-promise
// #docregion to-data
.then(response => response.json().data as Hero[])
// #enddocregion to-data
// #docregion catch
.map((data: any) => data.data as Hero[])
.catch(this.handleError);
// #enddocregion catch
}
// #enddocregion getHeroes
// #docregion getHero
getHero(id: number): Promise<Hero> {
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get(url)
.toPromise()
.then(response => response.json().data as Hero)
.map((data: any) => data.data as Hero)
.catch(this.handleError);
}
// #enddocregion getHero
// #docregion delete
delete(id: number): Promise<void> {
delete(id: number): Observable<void> {
const url = `${this.heroesUrl}/${id}`;
return this.http.delete(url, {headers: this.headers})
.toPromise()
.then(() => null)
return this.http.delete(url, { headers })
.catch(this.handleError);
}
// #enddocregion delete
// #docregion create
create(name: string): Promise<Hero> {
create(name: string): Observable<Hero> {
return this.http
.post(this.heroesUrl, JSON.stringify({name: name}), {headers: this.headers})
.toPromise()
.then(res => res.json().data)
.post(this.heroesUrl, { name: name }, { headers })
.map((data: any) => data.data)
.catch(this.handleError);
}
// #enddocregion create
// #docregion update
update(hero: Hero): Promise<Hero> {
update(hero: Hero): Observable<Hero> {
const url = `${this.heroesUrl}/${hero.id}`;
return this.http
.put(url, JSON.stringify(hero), {headers: this.headers})
.toPromise()
.then(() => hero)
.put(url, hero, { headers })
.catch(this.handleError);
}
// #enddocregion update
// #docregion getHeroes, handleError
private handleError(error: any): Promise<any> {
private handleError(error: any): Observable<any> {
console.error('An error occurred', error); // for demo purposes only
return Promise.reject(error.message || error);
throw error;
}
// #enddocregion getHeroes, handleError
}
@@ -21,31 +21,27 @@ export class HeroesComponent implements OnInit {
getHeroes(): void {
this.heroService
.getHeroes()
.then(heroes => this.heroes = heroes);
.subscribe(heroes => this.heroes = heroes);
}
// #docregion add
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.create(name)
.then(hero => {
.subscribe(hero => {
this.heroes.push(hero);
this.selectedHero = null;
});
}
// #enddocregion add
// #docregion delete
delete(hero: Hero): void {
this.heroService
.delete(hero.id)
.then(() => {
.subscribe(() => {
this.heroes = this.heroes.filter(h => h !== hero);
if (this.selectedHero === hero) { this.selectedHero = null; }
});
}
// #enddocregion delete
ngOnInit(): void {
this.getHeroes();
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<base href="/">
<title>Angular Tour of Heroes</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<my-app>Loading...</my-app>
</body>
<script src="node_modules/core-js/client/shim.min.js"></script>
<script src="node_modules/zone.js/dist/zone.min.js"></script>
<script src="dist/build.js"></script>
</html>
@@ -1,5 +0,0 @@
// #docregion
import { platformBrowser } from '@angular/platform-browser';
import { AppModuleNgFactory } from '../aot/src/app/app.module.ngfactory';
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);
@@ -1,40 +0,0 @@
import 'zone.js/dist/zone-node';
import { enableProdMode } from '@angular/core';
// import { AppServerModule } from './app.server';
import { AppServerModuleNgFactory } from '../../aot/src/uni/app.server.ngfactory';
import * as express from 'express';
import { ngUniversalEngine } from './universal-engine';
enableProdMode();
const server = express();
// set our angular engine as the handler for html files, so it will be used to render them.
server.engine('html', ngUniversalEngine({
bootstrap: [AppServerModuleNgFactory]
}));
// set default view directory
server.set('views', 'src');
// handle requests for routes in the app. ngExpressEngine does the rendering.
server.get(['/', '/dashboard', '/heroes', '/detail/:id'], (req, res) => {
res.render('index-aot.html', {req});
});
// handle requests for static files
server.get(['/*.js', '/*.css'], (req, res, next) => {
let fileName: string = req.originalUrl;
console.log(fileName);
let root = fileName.startsWith('/node_modules/') ? '.' : 'src';
res.sendFile(fileName, { root: root }, function (err) {
if (err) {
next(err);
}
});
});
// start the server
server.listen(3200, () => {
console.log('listening on port 3200...');
});
@@ -1,38 +0,0 @@
/**
* Express/Connect middleware for rendering pages using Angular Universal
*/
import * as fs from 'fs';
import { renderModuleFactory } from '@angular/platform-server';
const templateCache = {}; // cache for page templates
const outputCache = {}; // cache for rendered pages
export function ngUniversalEngine(setupOptions: any) {
return function (filePath: string, options: { req: Request }, callback: (err: Error, html: string) => void) {
let url: string = options.req.url;
let html: string = outputCache[url];
if (html) {
// return already-built page for this url
console.log('from cache: ' + url);
callback(null, html);
return;
}
console.log('building: ' + url);
if (!templateCache[filePath]) {
let file = fs.readFileSync(filePath);
templateCache[filePath] = file.toString();
}
// render the page via angular platform-server
let appModuleFactory = setupOptions.bootstrap[0];
renderModuleFactory(appModuleFactory, {
document: templateCache[filePath],
url: url
}).then(str => {
outputCache[url] = str;
callback(null, str);
});
};
}
@@ -1,21 +1,19 @@
// #docregion
import { NgModule } from '@angular/core';
import { APP_BASE_HREF } from '@angular/common';
import { ServerModule } from '@angular/platform-server';
import { AppComponent } from '../app/app.component';
import { AppModule } from '../app/app.module';
@NgModule({
imports: [
AppModule,
ServerModule,
AppModule
],
providers: [
// Add universal-only providers here
],
bootstrap: [
AppComponent
],
providers: [
{provide: APP_BASE_HREF, useValue: '/'}
// { provide: NgModuleFactoryLoader, useClass: ServerRouterLoader }
]
})
export class AppServerModule {
}
export class AppServerModule {}
@@ -0,0 +1,65 @@
// 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 compiler, is not available 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 (like index.html) in 'src/' by default
server.set('views', 'src');
// #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.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 node_modules or src
const root = fileName.startsWith('/node_modules/') ? '.' : 'src';
res.sendFile(fileName, { root }, err => {
if (err) { next(err); }
});
});
// #enddocregion static
// start the server
server.listen(port, () => {
console.log(`listening on port ${port}...`);
});
@@ -0,0 +1,62 @@
/**
* 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
const outputCache: { [key: string]: string } = {}; // rendered pages
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;
const html = outputCache[routeUrl];
if (html) {
// return already-built page for this url
console.log('from cache: ' + routeUrl);
callback(null, html);
return;
}
console.log('building: ' + routeUrl);
let template = templateCache[filePath];
if (!template) {
template = fs.readFileSync(filePath).toString();
templateCache[filePath] = template;
}
const { appModuleFactory } = setupOptions;
const origin = getOrigin(req);
// render the page
// #docregion render
renderModuleFactory(appModuleFactory, {
document: template,
url: routeUrl,
extraProviders: [
{ provide: APP_BASE_HREF, useValue: origin }
]
})
.then(page => {
outputCache[routeUrl] = 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,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/app/app.module.ts",
"src/main-aot.ts"
],
"angularCompilerOptions": {
"genDir": "aot",
"entryModule": "./src/app/app.module#AppModule",
"skipMetadataEmit" : true
}
}
@@ -15,8 +15,8 @@
},
"files": [
"src/uni/app.server.ts",
"src/uni/server-aot.ts"
"src/universal/app-server.module.ts",
"src/universal/server.ts"
],
"angularCompilerOptions": {
@@ -0,0 +1,40 @@
const ngtools = require('@ngtools/webpack');
const webpack = require('webpack');
module.exports = {
devtool: 'source-map',
// #docregion entry
entry: {
main: [
'./src/universal/app-server.module.ts',
'./src/universal/server.ts'
]
},
// #enddocregion entry
resolve: {
extensions: ['.ts', '.js']
},
target: 'node',
// #docregion output
output: {
path: 'src/dist',
filename: 'server.js'
},
// #enddocregion output
// #docregion plugins
plugins: [
new ngtools.AotPlugin({
tsConfigPath: './tsconfig-universal.json'
})
],
// #enddocregion plugins
// #docregion rules
module: {
rules: [
{ test: /\.css$/, loader: 'raw-loader' },
{ test: /\.html$/, loader: 'raw-loader' },
{ test: /\.ts$/, loader: '@ngtools/webpack' }
]
}
// #enddocregion rules
}
@@ -0,0 +1,9 @@
{
"files":[
"!**/*.d.ts",
"!**/src/**/*.js",
"!**/universal/**/*.js"
],
"removeSystemJsConfig": false,
"type": "universal"
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More