Skip to content

Commit 61973db

Browse files
committedDec 14, 2020
feat: add ssr with lambda@edge
1 parent 7ff2407 commit 61973db

33 files changed

+10873
-14088
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# dependencies
1111
/node_modules
1212

13+
.serverless
14+
1315
# profiling files
1416
chrome-profiler-events*.json
1517
speed-measure-plugin*.json

‎angular.json

+71-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"build": {
1818
"builder": "@angular-devkit/build-angular:browser",
1919
"options": {
20-
"outputPath": "dist/angular-lambda-ssr",
20+
"outputPath": "dist/angular-lambda-ssr/browser",
2121
"index": "src/index.html",
2222
"main": "src/main.ts",
2323
"polyfills": "src/polyfills.ts",
@@ -102,7 +102,8 @@
102102
"tsConfig": [
103103
"tsconfig.app.json",
104104
"tsconfig.spec.json",
105-
"e2e/tsconfig.json"
105+
"e2e/tsconfig.json",
106+
"tsconfig.server.json"
106107
],
107108
"exclude": [
108109
"**/node_modules/**"
@@ -120,6 +121,74 @@
120121
"devServerTarget": "angular-lambda-ssr:serve:production"
121122
}
122123
}
124+
},
125+
"serverless": {
126+
"builder": "@angular-devkit/build-angular:server",
127+
"options": {
128+
"outputPath": "dist/angular-lambda-ssr/serverless",
129+
"main": "serverless.ts",
130+
"tsConfig": "tsconfig.serverless.json"
131+
},
132+
"configurations": {
133+
"production": {
134+
"outputHashing": "media",
135+
"fileReplacements": [
136+
{
137+
"replace": "src/environments/environment.ts",
138+
"with": "src/environments/environment.prod.ts"
139+
}
140+
],
141+
"sourceMap": false,
142+
"optimization": true
143+
}
144+
}
145+
},
146+
"server": {
147+
"builder": "@angular-devkit/build-angular:server",
148+
"options": {
149+
"outputPath": "dist/angular-lambda-ssr/server",
150+
"main": "server.ts",
151+
"tsConfig": "tsconfig.server.json"
152+
},
153+
"configurations": {
154+
"production": {
155+
"outputHashing": "media",
156+
"fileReplacements": [
157+
{
158+
"replace": "src/environments/environment.ts",
159+
"with": "src/environments/environment.prod.ts"
160+
}
161+
],
162+
"sourceMap": false,
163+
"optimization": true
164+
}
165+
}
166+
},
167+
"serve-ssr": {
168+
"builder": "@nguniversal/builders:ssr-dev-server",
169+
"options": {
170+
"browserTarget": "angular-lambda-ssr:build",
171+
"serverTarget": "angular-lambda-ssr:server"
172+
},
173+
"configurations": {
174+
"production": {
175+
"browserTarget": "angular-lambda-ssr:build:production",
176+
"serverTarget": "angular-lambda-ssr:server:production"
177+
}
178+
}
179+
},
180+
"prerender": {
181+
"builder": "@nguniversal/builders:prerender",
182+
"options": {
183+
"browserTarget": "angular-lambda-ssr:build:production",
184+
"serverTarget": "angular-lambda-ssr:server:production",
185+
"routes": [
186+
"/"
187+
]
188+
},
189+
"configurations": {
190+
"production": {}
191+
}
123192
}
124193
}
125194
}

‎event.json

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"Records": [
3+
{
4+
"cf": {
5+
"config": {
6+
"distributionDomainName": "d123.cloudfront.net",
7+
"distributionId": "EDFDVBD6EXAMPLE",
8+
"eventType": "viewer-request",
9+
"requestId": "MRVMF7KydIvxMWfJIglgwHQwZsbG2IhRJ07sn9AkKUFSHS9EXAMPLE=="
10+
},
11+
"request": {
12+
"body": {
13+
"action": "read-only",
14+
"data": "eyJ1c2VybmFtZSI6IkxhbWJkYUBFZGdlIiwiY29tbWVudCI6IlRoaXMgaXMgcmVxdWVzdCBib2R5In0=",
15+
"encoding": "base64",
16+
"inputTruncated": false
17+
},
18+
"clientIp": "2001:0db8:85a3:0:0:8a2e:0370:7334",
19+
"querystring": "size=large",
20+
"uri": "/",
21+
"method": "GET",
22+
"headers": {
23+
"host": [
24+
{
25+
"key": "Host",
26+
"value": "d111111abcdef8.cloudfront.net"
27+
}
28+
],
29+
"user-agent": [
30+
{
31+
"key": "User-Agent",
32+
"value": "curl/7.51.0"
33+
}
34+
]
35+
},
36+
"origin": {
37+
"custom": {
38+
"customHeaders": {
39+
"my-origin-custom-header": [
40+
{
41+
"key": "My-Origin-Custom-Header",
42+
"value": "Test"
43+
}
44+
]
45+
},
46+
"domainName": "example.com",
47+
"keepaliveTimeout": 5,
48+
"path": "/custom_path",
49+
"port": 443,
50+
"protocol": "https",
51+
"readTimeout": 5,
52+
"sslProtocols": [
53+
"TLSv1",
54+
"TLSv1.1"
55+
]
56+
}
57+
}
58+
}
59+
}
60+
}
61+
]
62+
}

‎instructions.txt

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
- prerequisites:
3+
- aws account
4+
- aws cli configure
5+
- serverless framework installed
6+
7+
- install ng add @nguniversal/express-engine
8+
- add serverless.ts (to keep things clean)
9+
- add serverless in angular.json (to keep things clean)
10+
- add ServerTransferStateModule in app.server.module.ts
11+
- add TransferHttpCacheModule in app.module.ts
12+
- add lambda.js file
13+
- run yarn add html-minifier
14+
- add in package.json:
15+
"build:sls": "ng build --prod && ng run angular-lambda-ssr:serverless:production"
16+
- deploy serverless distribution:
17+
serverless deploy --config serverless-distribution.yml
18+
- replace the url in search.servicee with the cloudfront endpoint
19+
- build:
20+
yarn build:sls
21+
- upload dist to s3:
22+
aws s3 sync . s3://eelayoubi-ssr-test --profile A4L-MASTER
23+
- deploy lambda stack
24+
serverless deploy
25+
- attach lambda to cloudfront origin request
26+
27+
# cleanup
28+
- empty the bucket that contains the dist (if not empty, cannot be deleted)
29+
- delete distribution stack:
30+
serverless remove --config serverless-distribution.yml
31+
- wait for a while then delete lambda stack
32+
serverless remove
33+

‎lambda.js

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
const path = require('path')
3+
const serverless = require('serverless-http');
4+
const minify = require('html-minifier').minify;
5+
6+
const { app } = require("./dist/angular-lambda-ssr/serverless/main");
7+
8+
const handle = serverless(app, {
9+
provider: 'aws',
10+
type: 'lambda-edge-origin-request'
11+
});
12+
13+
const handler = async (event, context, callback) => {
14+
const request = event.Records[0].cf.request;
15+
16+
17+
if ((!path.extname(request.uri)) || (request.uri === '/index.html')) {
18+
const response = await handle(event, context);
19+
let minified = minify(response.body, {
20+
caseSensitive: true,
21+
collapseWhitespace: true,
22+
preserveLineBreaks: true,
23+
removeAttributeQuotes: true,
24+
removeComments: true
25+
});
26+
console.log('response: ', response)
27+
callback(null, {
28+
status: response.status,
29+
statusDescription: response.statusDescription,
30+
headers: {
31+
...response.headers,
32+
},
33+
body: minified,
34+
bodyEncoding: response.bodyEncoding
35+
});
36+
} else {
37+
console.log(`${request.uri} directly served from S3`)
38+
return request;
39+
}
40+
41+
}
42+
43+
exports.handler = handler;

‎package-lock.json

-13,546
This file was deleted.

‎package.json

+13-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
"build": "ng build",
88
"test": "ng test",
99
"lint": "ng lint",
10-
"e2e": "ng e2e"
10+
"e2e": "ng e2e",
11+
"dev:ssr": "ng run angular-lambda-ssr:serve-ssr",
12+
"serve:ssr": "node dist/angular-lambda-ssr/server/main.js",
13+
"build:ssr": "ng build --prod && ng run angular-lambda-ssr:server:production",
14+
"prerender": "ng run angular-lambda-ssr:prerender",
15+
"build:sls": "ng build --prod && ng run angular-lambda-ssr:serverless:production"
1116
},
1217
"private": true,
1318
"dependencies": {
@@ -18,15 +23,22 @@
1823
"@angular/forms": "~11.0.4",
1924
"@angular/platform-browser": "~11.0.4",
2025
"@angular/platform-browser-dynamic": "~11.0.4",
26+
"@angular/platform-server": "~11.0.4",
2127
"@angular/router": "~11.0.4",
28+
"@nguniversal/express-engine": "^11.0.1",
29+
"express": "^4.15.2",
30+
"html-minifier": "^4.0.0",
2231
"rxjs": "~6.6.0",
32+
"serverless-http": "eelayoubi/serverless-http",
2333
"tslib": "^2.0.0",
2434
"zone.js": "~0.10.2"
2535
},
2636
"devDependencies": {
2737
"@angular-devkit/build-angular": "~0.1100.4",
2838
"@angular/cli": "~11.0.4",
2939
"@angular/compiler-cli": "~11.0.4",
40+
"@nguniversal/builders": "^11.0.1",
41+
"@types/express": "^4.17.0",
3042
"@types/jasmine": "~3.6.0",
3143
"@types/node": "^12.11.1",
3244
"codelyzer": "^6.0.0",

‎server.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'zone.js/dist/zone-node';
2+
3+
import { ngExpressEngine } from '@nguniversal/express-engine';
4+
import * as express from 'express';
5+
import { join } from 'path';
6+
7+
import { AppServerModule } from './src/main.server';
8+
import { APP_BASE_HREF } from '@angular/common';
9+
import { existsSync } from 'fs';
10+
11+
// The Express app is exported so that it can be used by serverless Functions.
12+
export function app(): express.Express {
13+
const server = express();
14+
const distFolder = join(process.cwd(), 'dist/angular-lambda-ssr/browser');
15+
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
16+
17+
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
18+
server.engine('html', ngExpressEngine({
19+
bootstrap: AppServerModule,
20+
}));
21+
22+
server.set('view engine', 'html');
23+
server.set('views', distFolder);
24+
25+
// Example Express Rest API endpoints
26+
// server.get('/api/**', (req, res) => { });
27+
// Serve static files from /browser
28+
server.get('*.*', express.static(distFolder, {
29+
maxAge: '1y'
30+
}));
31+
32+
// All regular routes use the Universal engine
33+
server.get('*', (req, res) => {
34+
res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
35+
});
36+
37+
return server;
38+
}
39+
40+
function run(): void {
41+
const port = process.env.PORT || 4000;
42+
43+
// Start up the Node server
44+
const server = app();
45+
server.listen(port, () => {
46+
console.log(`Node Express server listening on http://localhost:${port}`);
47+
});
48+
}
49+
50+
// Webpack will replace 'require' with '__webpack_require__'
51+
// '__non_webpack_require__' is a proxy to Node 'require'
52+
// The below code is to ensure that the server is run only when not requiring the bundle.
53+
declare const __non_webpack_require__: NodeRequire;
54+
const mainModule = __non_webpack_require__.main;
55+
const moduleFilename = mainModule && mainModule.filename || '';
56+
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
57+
run();
58+
}
59+
60+
export * from './src/main.server';

‎serverless-distribution.yml

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
service: angular-lambda-ssr-distribution
2+
3+
provider:
4+
name: aws
5+
region: us-east-1
6+
7+
resources:
8+
Resources:
9+
10+
# the origin Identity that we will use in S3, to only allow a specific cloudfront distribution
11+
# access to the S3 content, to keep S3 private
12+
CloudFrontIdentity:
13+
Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
14+
Properties:
15+
CloudFrontOriginAccessIdentityConfig:
16+
Comment: cf-accessidentity
17+
18+
# the cloudfront distribution
19+
CloudFrontDistribution:
20+
Type: AWS::CloudFront::Distribution
21+
Properties:
22+
DistributionConfig:
23+
DefaultCacheBehavior:
24+
ForwardedValues:
25+
QueryString: true
26+
Cookies:
27+
Forward: none
28+
TargetOriginId: S3
29+
Compress: true
30+
ViewerProtocolPolicy: redirect-to-https
31+
MaxTTL: 86400
32+
DefaultTTL: 86400
33+
MinTTL: 86400
34+
DefaultRootObject: index.html
35+
HttpVersion: http2
36+
Enabled: true
37+
IPV6Enabled: true
38+
PriceClass: PriceClass_All
39+
Origins:
40+
- DomainName: !Join
41+
- ''
42+
- - !Ref MyS3Bucket
43+
- .s3.amazonaws.com
44+
S3OriginConfig:
45+
OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontIdentity}'
46+
Id: S3
47+
CustomErrorResponses:
48+
- ErrorCode: 404
49+
ResponseCode: 200
50+
ResponsePagePath: /index.html
51+
ErrorCachingMinTTL: 300
52+
- ErrorCode: 403
53+
ResponseCode: 200
54+
ResponsePagePath: /index.html
55+
ErrorCachingMinTTL: 300
56+
57+
# the S3 bucket we will use to upload our application (the dist folder)
58+
MyS3Bucket:
59+
Type: AWS::S3::Bucket
60+
Properties:
61+
BucketName: replacewithyourbucketname
62+
63+
# the policy that grants access to S3 to only the cloudfront origin identity
64+
MyS3BucketPolicy:
65+
Type: AWS::S3::BucketPolicy
66+
Properties:
67+
Bucket: !Ref MyS3Bucket
68+
PolicyDocument:
69+
Statement:
70+
Effect: "Allow"
71+
Principal:
72+
AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontIdentity}"
73+
Action: "s3:GetObject"
74+
Resource: !Join
75+
- ''
76+
- - 'arn:aws:s3:::'
77+
- !Ref MyS3Bucket
78+
- /*
79+
80+
Outputs:
81+
CloudFrontEndpoint:
82+
Description: Endpoint for Cloudfront Distribution
83+
Value: !GetAtt CloudFrontDistribution.DomainName

‎serverless.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'zone.js/dist/zone-node';
2+
import { join } from 'path';
3+
import * as fs from 'fs';
4+
import { renderModule, AppServerModule } from './src/main.server';
5+
import * as express from 'express';
6+
7+
export const app = express();
8+
9+
const distFolder = join(process.cwd(), 'dist/angular-lambda-ssr/browser');
10+
11+
app.set('view engine', 'html');
12+
app.set('views', distFolder);
13+
14+
// Example Express Rest API endpoints
15+
// app.get('/api/**', (req, res) => { });
16+
// Serve static files from /browser
17+
app.get('*.*', express.static(distFolder, {
18+
maxAge: '1y'
19+
}));
20+
21+
// All regular routes use the Universal engine
22+
app.get('*', (req, res) => {
23+
fs.readFile(join(__dirname, '../browser/index.html'), function (err, html) {
24+
if (err) {
25+
throw err;
26+
}
27+
28+
renderModule(AppServerModule, {
29+
document: html.toString(),
30+
url: req.url
31+
}).then((html) => {
32+
res.send(html);
33+
});
34+
});
35+
36+
});
37+
38+
export * from './src/main.server';

‎serverless.yml

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
service: angular-lambda-ssr
2+
3+
provider:
4+
name: aws
5+
runtime: nodejs12.x
6+
memorySize: 192
7+
timeout: 10
8+
region: us-east-1
9+
10+
package:
11+
exclude:
12+
- ./**
13+
include:
14+
- "node_modules/html-minifier/**"
15+
- "node_modules/commander/**"
16+
- "node_modules/html-minifier/**"
17+
- "node_modules/upper-case/**"
18+
- "node_modules/clean-css/**"
19+
- "node_modules/source-map/**"
20+
- "node_modules/lower-case/**"
21+
- "node_modules/no-case/**"
22+
- "node_modules/he/**"
23+
- "node_modules/param-case/**"
24+
- "node_modules/relateurl/**"
25+
- "node_modules/uglify-js/**"
26+
- "node_modules/serverless-http/**"
27+
- "dist/**"
28+
- "lambda.js"
29+
30+
functions:
31+
ssr-origin-req:
32+
handler: lambda.handler
33+
role: !GetAtt LambdaEdgeFunctionRole.Arn
34+
35+
resources:
36+
Resources:
37+
# the role that the lambda function will assume. Also notice the edgelambda in the principal part
38+
LambdaEdgeFunctionRole:
39+
Type: "AWS::IAM::Role"
40+
Properties:
41+
Path: "/"
42+
ManagedPolicyArns:
43+
- "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
44+
AssumeRolePolicyDocument:
45+
Version: "2012-10-17"
46+
Statement:
47+
-
48+
Sid: "AllowLambdaServiceToAssumeRole"
49+
Effect: "Allow"
50+
Action:
51+
- "sts:AssumeRole"
52+
Principal:
53+
Service:
54+
- "lambda.amazonaws.com"
55+
- "edgelambda.amazonaws.com"
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NgModule } from '@angular/core';
2+
import { Routes, RouterModule } from '@angular/router';
3+
4+
import { AnimalComponent } from './animal.component';
5+
6+
7+
const routes: Routes = [
8+
{
9+
path: ':id',
10+
component: AnimalComponent
11+
}
12+
];
13+
14+
@NgModule({
15+
imports: [RouterModule.forChild(routes)],
16+
exports: [RouterModule]
17+
})
18+
export class AnimalRoutingModule { }

‎src/app/animal/animal.component.css

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:host {
2+
display: block;
3+
padding: 0 20px;
4+
}

‎src/app/animal/animal.component.html

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div *ngIf="animal">
2+
<fieldset>
3+
<legend>Details</legend><br />
4+
<label for="name">Name:</label>
5+
<span> {{ animal.name }}</span><br /><br />
6+
<label for="age">Age:</label>
7+
<span> {{ animal.age }}</span><br /><br />
8+
<label for="address">Adders:</label>
9+
<span> {{ animal.address.street }} {{ animal.address.city }} {{ animal.address.zip }}</span><br /><br />
10+
<label for="charac">Characteristics:</label>
11+
<ul *ngFor="let charac of animal.characteristics;">
12+
<li>{{charac}}</li>
13+
</ul>
14+
</fieldset>
15+
</div>

‎src/app/animal/animal.component.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Component, OnInit, OnDestroy } from '@angular/core';
2+
import { Animal, SearchService } from '../shared';
3+
import { Subject } from 'rxjs';
4+
import { ActivatedRoute, Router } from '@angular/router';
5+
import { takeUntil } from 'rxjs/operators';
6+
7+
@Component({
8+
selector: 'app-animal',
9+
templateUrl: './animal.component.html',
10+
styleUrls: ['./animal.component.css']
11+
})
12+
export class AnimalComponent implements OnInit, OnDestroy {
13+
animal: Animal;
14+
15+
private ngUnsubscribe: Subject<void> = new Subject<void>();
16+
17+
constructor(private route: ActivatedRoute,
18+
private router: Router,
19+
private service: SearchService) {
20+
}
21+
22+
ngOnInit(): void {
23+
this.route.params.subscribe((params) => {
24+
const id = + params.id; // (+) converts string 'id' to a number
25+
this.service.get(id)
26+
.pipe(takeUntil(this.ngUnsubscribe))
27+
.subscribe(animal => {
28+
if (animal) {
29+
this.animal = animal;
30+
} else {
31+
this.gotoList();
32+
}
33+
});
34+
});
35+
}
36+
37+
ngOnDestroy(): void {
38+
this.ngUnsubscribe.next();
39+
this.ngUnsubscribe.complete();
40+
}
41+
42+
cancel() {
43+
this.router.navigate(['/search']);
44+
}
45+
46+
gotoList() {
47+
if (this.animal) {
48+
this.router.navigate(['/search', { term: this.animal.name }]);
49+
} else {
50+
this.router.navigate(['/search']);
51+
}
52+
}
53+
}

‎src/app/animal/animal.module.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormsModule } from '@angular/forms';
4+
import { AnimalRoutingModule } from './animal-routing.module';
5+
import { AnimalComponent } from './animal.component';
6+
7+
@NgModule({
8+
imports: [
9+
CommonModule,
10+
FormsModule,
11+
AnimalRoutingModule
12+
],
13+
declarations: [AnimalComponent]
14+
})
15+
export class AnimalModule { }

‎src/app/app-routing.module.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { NgModule } from '@angular/core';
22
import { Routes, RouterModule } from '@angular/router';
33

4-
const routes: Routes = [];
4+
import { SearchComponent } from './search/search.component';
5+
6+
const routes: Routes = [
7+
{ path: 'search', component: SearchComponent },
8+
{
9+
path: 'animal',
10+
loadChildren: () => import('./animal/animal.module').then(m => m.AnimalModule)
11+
},
12+
{ path: '', redirectTo: '/search', pathMatch: 'full' }
13+
];
14+
515

616
@NgModule({
7-
imports: [RouterModule.forRoot(routes)],
17+
imports: [RouterModule.forRoot(routes, {
18+
initialNavigation: 'enabled'
19+
})],
820
exports: [RouterModule]
921
})
1022
export class AppRoutingModule { }

‎src/app/app.component.html

+1-532
Large diffs are not rendered by default.

‎src/app/app.module.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { BrowserModule } from '@angular/platform-browser';
22
import { NgModule } from '@angular/core';
3+
import { TransferHttpCacheModule } from '@nguniversal/common';
4+
import { HttpClientModule } from '@angular/common/http';
5+
import { FormsModule } from '@angular/forms';
36

47
import { AppRoutingModule } from './app-routing.module';
58
import { AppComponent } from './app.component';
9+
import { SearchComponent } from './search/search.component';
610

711
@NgModule({
812
declarations: [
9-
AppComponent
13+
AppComponent,
14+
SearchComponent
1015
],
1116
imports: [
12-
BrowserModule,
13-
AppRoutingModule
17+
BrowserModule.withServerTransition({ appId: 'serverApp' }),
18+
TransferHttpCacheModule,
19+
AppRoutingModule,
20+
FormsModule,
21+
HttpClientModule
1422
],
1523
providers: [],
1624
bootstrap: [AppComponent]

‎src/app/app.server.module.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NgModule } from '@angular/core';
2+
import { ServerModule , ServerTransferStateModule} from '@angular/platform-server';
3+
4+
import { AppModule } from './app.module';
5+
import { AppComponent } from './app.component';
6+
7+
@NgModule({
8+
imports: [
9+
AppModule,
10+
ServerModule,
11+
ServerTransferStateModule
12+
],
13+
bootstrap: [AppComponent],
14+
})
15+
export class AppServerModule {}

‎src/app/search/search.component.css

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
:host {
2+
display: block;
3+
padding: 0 20px;
4+
}
5+
6+
table {
7+
margin-top: 10px;
8+
border-collapse: collapse;
9+
}
10+
11+
th {
12+
text-align: left;
13+
border-bottom: 2px solid #ddd;
14+
padding: 8px;
15+
}
16+
17+
td {
18+
border-top: 1px solid #ddd;
19+
padding: 8px;
20+
}

‎src/app/search/search.component.html

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<h2>Search for a pet:</h2>
2+
<form>
3+
<input type="search" name="query" [(ngModel)]="query" (keyup.enter)="search()">
4+
<button type="button" (click)="search()">Search</button>
5+
</form>
6+
<table *ngIf="searchResults.length">
7+
<thead>
8+
<tr>
9+
<th>Name</th>
10+
<th>Age</th>
11+
<th>Address</th>
12+
</tr>
13+
</thead>
14+
<tbody>
15+
<tr *ngFor="let animal of searchResults; let i=index">
16+
<td><a [routerLink]="['/animal', animal.id]">{{animal.name}}</a></td>
17+
<td>{{animal.age}}</td>
18+
<td>{{animal.address.street}}<br />
19+
{{animal.address.city}} {{animal.address.zip}}
20+
</td>
21+
</tr>
22+
</tbody>
23+
</table>

‎src/app/search/search.component.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Component, OnInit, OnDestroy } from '@angular/core';
2+
import { ActivatedRoute } from '@angular/router';
3+
import { Subject } from 'rxjs';
4+
import { takeUntil } from 'rxjs/operators';
5+
6+
import { Animal, SearchService } from '../shared';
7+
8+
@Component({
9+
selector: 'app-search',
10+
templateUrl: './search.component.html',
11+
styleUrls: ['./search.component.css']
12+
})
13+
export class SearchComponent implements OnInit, OnDestroy {
14+
query = '';
15+
searchResults: Array<Animal> = [];
16+
private ngUnsubscribe: Subject<void> = new Subject<void>();
17+
18+
constructor(private searchService: SearchService, private route: ActivatedRoute) { }
19+
20+
ngOnInit(): void {
21+
this.route.params
22+
.pipe(takeUntil(this.ngUnsubscribe))
23+
.subscribe(params => {
24+
if (params.term) {
25+
this.query = decodeURIComponent(params.term);
26+
this.search();
27+
}
28+
});
29+
}
30+
31+
ngOnDestroy(): void {
32+
this.ngUnsubscribe.next();
33+
this.ngUnsubscribe.complete();
34+
}
35+
36+
search(): void {
37+
this.searchService.search(this.query).subscribe(
38+
(data: any) => { this.searchResults = data; },
39+
error => console.log(error)
40+
);
41+
}
42+
43+
}

‎src/app/shared/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './search/search.service';
2+
export * from './interfaces';

‎src/app/shared/interfaces.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface Address {
2+
street: string;
3+
city: string;
4+
zip: string;
5+
}
6+
7+
export interface Animal {
8+
id: number;
9+
name: string;
10+
age: string;
11+
address: Address;
12+
characteristics: [string]
13+
}
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
2+
import { HttpClient } from '@angular/common/http';
3+
import { Observable } from 'rxjs';
4+
import { map } from 'rxjs/operators';
5+
import { Animal } from '../interfaces';
6+
7+
@Injectable({
8+
providedIn: 'root'
9+
})
10+
export class SearchService {
11+
12+
constructor(private http: HttpClient, @Inject(PLATFORM_ID) readonly platformId: Object) { }
13+
14+
getAll() {
15+
return this.http.get('/assets/data/animals.json');
16+
}
17+
18+
search(q: string): Observable<Animal[]> {
19+
if (!q || q === '*') {
20+
q = '';
21+
} else {
22+
q = q.toLowerCase();
23+
}
24+
return this.getAll().pipe(
25+
map((data: Animal[]) =>
26+
data
27+
.filter(item => JSON.stringify(item).toLowerCase().includes(q))
28+
));
29+
}
30+
31+
get(id: number) {
32+
return this.getAll().pipe(
33+
map((all: any) => all.find(e => e.id === id)));
34+
}
35+
}

‎src/assets/data/animals.json

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
[
2+
{
3+
"id": 1,
4+
"name": "Oliver",
5+
"type": "cat",
6+
"age": "12",
7+
"address": {
8+
"street": "2 dam",
9+
"city": "Amsterdam",
10+
"zip": "1012"
11+
},
12+
"characteristics": [
13+
"friendly",
14+
"playful",
15+
"selfish"
16+
]
17+
},
18+
{
19+
"id": 2,
20+
"name": "Leo",
21+
"type": "cat",
22+
"age": "1",
23+
"address": {
24+
"street": "26 ",
25+
"city": "Haarlem",
26+
"zip": "2002"
27+
},
28+
"characteristics": [
29+
"friendly",
30+
"quite",
31+
"cuddly"
32+
]
33+
},
34+
{
35+
"id": 3,
36+
"name": "Simba",
37+
"type": "cat",
38+
"age": "4",
39+
"address": {
40+
"street": "123 markt",
41+
"city": "Amsterdam",
42+
"zip": "1003"
43+
},
44+
"characteristics": [
45+
"shy"
46+
]
47+
},
48+
{
49+
"id": 4,
50+
"name": "Max",
51+
"type": "dog",
52+
"age": "13",
53+
"address": {
54+
"street": "12 gracht",
55+
"city": "Amsterdam",
56+
"zip": "1017"
57+
},
58+
"characteristics": [
59+
"friendly",
60+
"Affectionate with Family",
61+
"selfish"
62+
]
63+
},
64+
{
65+
"id": 5,
66+
"name": "Milo",
67+
"type": "dog",
68+
"age": "15",
69+
"address": {
70+
"street": "43 kade",
71+
"city": "Amsterdam",
72+
"zip": "1345"
73+
},
74+
"characteristics": [
75+
"Kid-friendly",
76+
"playful"
77+
]
78+
},
79+
{
80+
"id": 6,
81+
"name": "Oscar",
82+
"type": "dog",
83+
"age": "2",
84+
"address": {
85+
"street": "34 haven",
86+
"city": "Amsterdam",
87+
"zip": "1057"
88+
},
89+
"characteristics": [
90+
"friendly",
91+
"playful",
92+
"Love to eat"
93+
]
94+
}
95+
]

‎src/main.server.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { enableProdMode } from '@angular/core';
2+
3+
import { environment } from './environments/environment';
4+
5+
if (environment.production) {
6+
enableProdMode();
7+
}
8+
9+
export { AppServerModule } from './app/app.server.module';
10+
export { renderModule, renderModuleFactory } from '@angular/platform-server';

‎src/main.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@ if (environment.production) {
88
enableProdMode();
99
}
1010

11-
platformBrowserDynamic().bootstrapModule(AppModule)
11+
document.addEventListener('DOMContentLoaded', () => {
12+
platformBrowserDynamic().bootstrapModule(AppModule)
1213
.catch(err => console.error(err));
14+
});

‎tsconfig.json

-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"baseUrl": "./",
66
"outDir": "./dist/out-tsc",
77
"forceConsistentCasingInFileNames": true,
8-
"strict": true,
98
"noImplicitReturns": true,
109
"noFallthroughCasesInSwitch": true,
1110
"sourceMap": true,

‎tsconfig.server.json

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* To learn more about this file see: https://angular.io/config/tsconfig. */
2+
{
3+
"extends": "./tsconfig.app.json",
4+
"compilerOptions": {
5+
"outDir": "./out-tsc/server",
6+
"target": "es2016",
7+
"types": [
8+
"node"
9+
]
10+
},
11+
"files": [
12+
"src/main.server.ts",
13+
"server.ts"
14+
],
15+
"angularCompilerOptions": {
16+
"entryModule": "./src/app/app.server.module#AppServerModule"
17+
}
18+
}

‎tsconfig.serverless.json

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"extends": "./tsconfig.app.json",
3+
"compilerOptions": {
4+
"outDir": "./out-tsc/serverless",
5+
"target": "es2016",
6+
"types": [
7+
"node"
8+
]
9+
},
10+
"files": [
11+
"src/main.server.ts",
12+
"serverless.ts"
13+
],
14+
"angularCompilerOptions": {
15+
"entryModule": "./src/app/app.server.module#AppServerModule"
16+
}
17+
}

‎yarn.lock

+9,988
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.