Skip to content
This repository was archived by the owner on Apr 14, 2025. It is now read-only.

Commit 70e2730

Browse files
committed
feat: server side rendering
1 parent 6b6d5aa commit 70e2730

13 files changed

+219
-16
lines changed

angular.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
"inlineStyleLanguage": "scss",
2626
"assets": ["src/favicon.ico", "src/assets"],
2727
"styles": ["src/styles.scss"],
28-
"scripts": []
28+
"scripts": [],
29+
"server": "src/main.server.ts",
30+
"prerender": false,
31+
"ssr": {
32+
"entry": "server.ts"
33+
}
2934
},
3035
"configurations": {
3136
"production": {

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"watch": "ng build --watch --configuration development",
1010
"test": "ng test",
1111
"test:ci": "ng test --browsers=ChromeHeadless --watch=false --code-coverage",
12-
"measure": "node scripts/measure.mjs"
12+
"measure": "node scripts/measure.mjs",
13+
"serve:ssr": "node dist/angular-music/server/server.mjs"
1314
},
1415
"private": true,
1516
"dependencies": {

server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { APP_BASE_HREF } from '@angular/common';
2+
import { CommonEngine } from '@angular/ssr';
3+
import express from 'express';
4+
import cookieParser from 'cookie-parser';
5+
import { fileURLToPath } from 'node:url';
6+
import { dirname, join, resolve } from 'node:path';
7+
import bootstrap from './src/main.server';
8+
import { REQUEST, RESPONSE } from './src/app/ssr.tokens';
9+
10+
// The Express app is exported so that it can be used by serverless Functions.
11+
export function app(): express.Express {
12+
const server = express();
13+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
14+
const browserDistFolder = resolve(serverDistFolder, '../browser');
15+
const indexHtml = join(serverDistFolder, 'index.server.html');
16+
17+
server.use(cookieParser());
18+
19+
const commonEngine = new CommonEngine();
20+
21+
server.set('view engine', 'html');
22+
server.set('views', browserDistFolder);
23+
24+
// Example Express Rest API endpoints
25+
// server.get('/api/**', (req, res) => { });
26+
// Serve static files from /browser
27+
server.get(
28+
'*.*',
29+
express.static(browserDistFolder, {
30+
maxAge: '1y',
31+
}),
32+
);
33+
34+
// All regular routes use the Angular engine
35+
server.get('*', (req, res, next) => {
36+
const { protocol, originalUrl, baseUrl, headers } = req;
37+
38+
// The server.ts not used for ng serve, so REQUEST and RESPONSE tokens won't be set
39+
// https://github.com/angular/angular-cli/issues/26323
40+
41+
commonEngine
42+
.render({
43+
bootstrap,
44+
documentFilePath: indexHtml,
45+
url: `${protocol}://${headers.host}${originalUrl}`,
46+
publicPath: browserDistFolder,
47+
providers: [
48+
{ provide: APP_BASE_HREF, useValue: baseUrl },
49+
{ provide: REQUEST, useValue: req },
50+
{ provide: RESPONSE, useValue: res },
51+
],
52+
})
53+
.then((html) => res.send(html))
54+
.catch((err) => next(err));
55+
});
56+
57+
return server;
58+
}
59+
60+
// function run(): void {
61+
// const port = process.env['PORT'] || 4000;
62+
//
63+
// // Start up the Node server
64+
// const server = app();
65+
// server.listen(port, () => {
66+
// console.log(`Node Express server listening on http://localhost:${port}`);
67+
// });
68+
// }
69+
//
70+
// run();

src/app/app.config.server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
2+
import { provideServerRendering } from '@angular/platform-server';
3+
import { appConfig } from './app.config';
4+
import { ServerCacheStoreService } from './spotify-client/server-cache-store.service';
5+
import { CACHE_STORE_TOKEN } from './spotify-client/spotify-client.service';
6+
import { mockResizeObserverFactory, RESIZE_OBSERVER_FACTORY } from './shared/resize-observer';
7+
8+
const serverConfig: ApplicationConfig = {
9+
providers: [
10+
provideServerRendering(),
11+
{
12+
provide: CACHE_STORE_TOKEN,
13+
useClass: ServerCacheStoreService,
14+
},
15+
{
16+
provide: RESIZE_OBSERVER_FACTORY,
17+
useValue: mockResizeObserverFactory,
18+
},
19+
],
20+
};
21+
22+
export const config = mergeApplicationConfig(appConfig, serverConfig);

src/app/app.config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
import { ApplicationConfig } from '@angular/core';
22
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
3-
import { provideRouter, withRouterConfig } from '@angular/router';
3+
import {
4+
provideRouter,
5+
withEnabledBlockingInitialNavigation,
6+
withRouterConfig,
7+
} from '@angular/router';
48
import { routes } from './app.routes';
5-
import { provideHttpClient, withInterceptors } from '@angular/common/http';
9+
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
610
import { authenticationInterceptor } from './spotify-client/authentication.interceptor';
711
import { CACHE_STORE_TOKEN } from './spotify-client/spotify-client.service';
812
import { BrowserCacheStoreService } from './spotify-client/browser-cache-store.service';
13+
import { provideClientHydration } from '@angular/platform-browser';
914

1015
export const appConfig: ApplicationConfig = {
1116
providers: [
1217
provideAnimationsAsync(),
1318
provideRouter(
1419
routes,
1520
withRouterConfig({ onSameUrlNavigation: 'reload' }),
21+
withEnabledBlockingInitialNavigation(),
1622
),
17-
provideHttpClient(withInterceptors([authenticationInterceptor])),
23+
provideHttpClient(withFetch(), withInterceptors([authenticationInterceptor])),
1824
{
1925
provide: CACHE_STORE_TOKEN,
2026
useClass: BrowserCacheStoreService,
2127
},
28+
provideClientHydration(),
2229
],
2330
};

src/app/shared/card-list/card-list.component.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
Component,
55
ElementRef,
66
EventEmitter,
7+
Inject,
78
Input,
89
OnDestroy,
910
Output,
1011
ViewChild,
1112
} from '@angular/core';
1213
import { CardItem, ClickableCardComponent } from '../clickable-card/clickable-card.component';
1314
import { NgClass } from '@angular/common';
15+
import { RESIZE_OBSERVER_FACTORY, ResizeObserverFactory } from '../resize-observer';
1416

1517
const CARD_WIDTH = 128;
1618
const GAB_WIDTH = 24;
@@ -38,8 +40,11 @@ export class CardListComponent implements AfterViewInit, OnDestroy {
3840

3941
private resizeObserver: ResizeObserver;
4042

41-
constructor(private cdr: ChangeDetectorRef) {
42-
this.resizeObserver = new ResizeObserver(this.onResize);
43+
constructor(
44+
private cdr: ChangeDetectorRef,
45+
@Inject(RESIZE_OBSERVER_FACTORY) resizeObserverFactory: ResizeObserverFactory,
46+
) {
47+
this.resizeObserver = resizeObserverFactory(this.onResize);
4348
}
4449

4550
ngAfterViewInit(): void {

src/app/shared/hero-header/hero-header.component.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Component, inject, Input, NgZone, OnChanges, SimpleChanges } from '@angular/core';
2-
import { NgOptimizedImage } from '@angular/common';
1+
import { Component, inject, Input, NgZone, OnChanges, PLATFORM_ID, SimpleChanges } from '@angular/core';
2+
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
33
import { injectLazy } from 'ngxtension/inject-lazy';
44
import { findBestMatchingImage, Image } from '../images';
55

@@ -15,6 +15,7 @@ export class HeroHeaderComponent implements OnChanges {
1515
() => import('./hero-header-animation.service'),
1616
);
1717
private readonly ngZone = inject(NgZone);
18+
private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1819

1920
@Input({ required: true }) heroData!: HeroData | null;
2021
headerImage = '';
@@ -30,9 +31,11 @@ export class HeroHeaderComponent implements OnChanges {
3031
}
3132

3233
private initAnimation() {
33-
this.heroHeaderAnimation$.subscribe((animation) =>
34-
this.ngZone.runOutsideAngular(() => animation.init()),
35-
);
34+
if (this.isBrowser) {
35+
this.heroHeaderAnimation$.subscribe((animation) =>
36+
this.ngZone.runOutsideAngular(() => animation.init()),
37+
);
38+
}
3639
}
3740
}
3841

src/app/shared/resize-observer.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
export type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;
4+
5+
const browserResizeObserverFactory: ResizeObserverFactory = (callback) =>
6+
new ResizeObserver(callback);
7+
8+
export const RESIZE_OBSERVER_FACTORY = new InjectionToken<ResizeObserverFactory>(
9+
'ResizeObserverFactory',
10+
{
11+
factory: () => browserResizeObserverFactory,
12+
},
13+
);
14+
15+
export const mockResizeObserverFactory: ResizeObserverFactory = () => mockResizeObserver;
16+
17+
const mockResizeObserver: ResizeObserver = {
18+
observe() {},
19+
disconnect() {},
20+
unobserve() {},
21+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { inject, Injectable } from '@angular/core';
2+
import { ICacheStore } from '@spotify/web-api-ts-sdk/src/caching/ICacheStore';
3+
import { REQUEST, RESPONSE } from '../ssr.tokens';
4+
5+
// Firebase hosting only allows the `__session` cookie
6+
// https://firebase.google.com/docs/hosting/manage-cache#using_cookies
7+
const sessionCookieKey = '__session';
8+
9+
@Injectable()
10+
export class ServerCacheStoreService implements ICacheStore {
11+
// use "optional: true" to work with pre rendering and ng serve
12+
// https://github.com/angular/angular-cli/issues/26323
13+
private readonly request = inject(REQUEST, { optional: true });
14+
private readonly response = inject(RESPONSE, { optional: true });
15+
16+
get(key: string): string | null {
17+
return this.getSessionCookie()?.[key] ?? null;
18+
}
19+
20+
remove(key: string): void {
21+
const sessionCookie = this.getSessionCookie();
22+
if (sessionCookie) {
23+
delete sessionCookie[key];
24+
this.saveSessionCookie(sessionCookie);
25+
}
26+
}
27+
28+
set(key: string, value: string): void {
29+
const sessionCookie = this.getSessionCookie() ?? {};
30+
sessionCookie[key] = value;
31+
this.saveSessionCookie(sessionCookie);
32+
}
33+
34+
private getSessionCookie(): Record<string, string> | null {
35+
const sessionCookie = this.request?.cookies[sessionCookieKey];
36+
if (!sessionCookie) {
37+
return null;
38+
}
39+
return JSON.parse(sessionCookie);
40+
}
41+
42+
private saveSessionCookie(sessionCookie: Record<string, string>) {
43+
if (Object.keys(sessionCookie).length === 0) {
44+
this.response?.clearCookie(sessionCookieKey);
45+
} else {
46+
this.response?.cookie(sessionCookieKey, JSON.stringify(sessionCookie), { maxAge: 604800000 });
47+
}
48+
}
49+
}

src/app/spotify-client/spotify-client.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { Inject, Injectable, InjectionToken } from '@angular/core';
1+
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
22
import { SpotifyAuthentication } from './spotify-authentication';
33
import { ICacheStore } from '@spotify/web-api-ts-sdk';
44
import { DOCUMENT } from '@angular/common';
5+
import { REQUEST } from '../ssr.tokens';
6+
import { Request } from 'express';
57

68
const CLIENT_ID = 'eda234756aae490988e32cb92412225d';
79

@@ -16,8 +18,13 @@ export class SpotifyClientService {
1618
constructor(
1719
@Inject(CACHE_STORE_TOKEN) cacheStore: ICacheStore,
1820
@Inject(DOCUMENT) document: Document,
21+
@Inject(REQUEST) @Optional() request: Request,
1922
) {
20-
const redirectUri = `${document.location.origin}/callback`;
23+
// TODO move somewhere else
24+
const host = request
25+
? request.header('x-forwarded-proto') + `://` + request.header('x-forwarded-host')
26+
: document.location.origin;
27+
const redirectUri = `${host}/callback`;
2128
this.spotifyAuthentication = new SpotifyAuthentication(
2229
CLIENT_ID,
2330
redirectUri,

0 commit comments

Comments
 (0)