Skip to content

Commit

Permalink
feat: server side rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
sonallux committed Mar 9, 2024
1 parent f1db813 commit 14718dc
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 22 deletions.
16 changes: 9 additions & 7 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser-esbuild",
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/angular-music",
"index": "src/index.html",
"main": "src/main.ts",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
"scripts": [],
"server": "src/main.server.ts",
"prerender": false,
"ssr": {
"entry": "server.ts"
}
},
"configurations": {
"production": {
Expand All @@ -44,12 +49,9 @@
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
"sourceMap": true
}
},
"defaultConfiguration": "production"
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:ci": "ng test --browsers=ChromeHeadless --watch=false --code-coverage",
"measure": "node scripts/measure.mjs"
"measure": "node scripts/measure.mjs",
"serve:ssr": "node dist/angular-music/server/server.mjs"
},
"private": true,
"dependencies": {
Expand Down
70 changes: 70 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import cookieParser from 'cookie-parser';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
import { REQUEST, RESPONSE } from './src/app/ssr.tokens';

// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');

server.use(cookieParser());

const commonEngine = new CommonEngine();

server.set('view engine', 'html');
server.set('views', browserDistFolder);

// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get(
'*.*',
express.static(browserDistFolder, {
maxAge: '1y',
}),
);

// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;

// The server.ts not used for ng serve, so REQUEST and RESPONSE tokens won't be set
// https://github.com/angular/angular-cli/issues/26323

commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl },
{ provide: REQUEST, useValue: req },
{ provide: RESPONSE, useValue: res },
],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});

return server;
}

// function run(): void {
// const port = process.env['PORT'] || 4000;
//
// // Start up the Node server
// const server = app();
// server.listen(port, () => {
// console.log(`Node Express server listening on http://localhost:${port}`);
// });
// }
//
// run();
22 changes: 22 additions & 0 deletions src/app/app.config.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
import { ServerCacheStoreService } from './spotify-client/server-cache-store.service';
import { CACHE_STORE_TOKEN } from './spotify-client/spotify-client.service';
import { mockResizeObserverFactory, RESIZE_OBSERVER_FACTORY } from './shared/resize-observer';

const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
{
provide: CACHE_STORE_TOKEN,
useClass: ServerCacheStoreService,
},
{
provide: RESIZE_OBSERVER_FACTORY,
useValue: mockResizeObserverFactory,
},
],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);
13 changes: 10 additions & 3 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { ApplicationConfig } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, withRouterConfig } from '@angular/router';
import {
provideRouter,
withEnabledBlockingInitialNavigation,
withRouterConfig,
} from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { authenticationInterceptor } from './spotify-client/authentication.interceptor';
import { CACHE_STORE_TOKEN } from './spotify-client/spotify-client.service';
import { BrowserCacheStoreService } from './spotify-client/browser-cache-store.service';
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
providers: [
provideAnimationsAsync(),
provideRouter(
routes,
withRouterConfig({ onSameUrlNavigation: 'reload' }),
withEnabledBlockingInitialNavigation(),
),
provideHttpClient(withInterceptors([authenticationInterceptor])),
provideHttpClient(withFetch(), withInterceptors([authenticationInterceptor])),
{
provide: CACHE_STORE_TOKEN,
useClass: BrowserCacheStoreService,
},
provideClientHydration(),
],
};
9 changes: 7 additions & 2 deletions src/app/shared/card-list/card-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
Component,
ElementRef,
EventEmitter,
Inject,
Input,
OnDestroy,
Output,
ViewChild,
} from '@angular/core';
import { CardItem, ClickableCardComponent } from '../clickable-card/clickable-card.component';
import { NgClass } from '@angular/common';
import { RESIZE_OBSERVER_FACTORY, ResizeObserverFactory } from '../resize-observer';

const CARD_WIDTH = 128;
const GAB_WIDTH = 24;
Expand Down Expand Up @@ -38,8 +40,11 @@ export class CardListComponent implements AfterViewInit, OnDestroy {

private resizeObserver: ResizeObserver;

constructor(private cdr: ChangeDetectorRef) {
this.resizeObserver = new ResizeObserver(this.onResize);
constructor(
private cdr: ChangeDetectorRef,
@Inject(RESIZE_OBSERVER_FACTORY) resizeObserverFactory: ResizeObserverFactory,
) {
this.resizeObserver = resizeObserverFactory(this.onResize);
}

ngAfterViewInit(): void {
Expand Down
13 changes: 8 additions & 5 deletions src/app/shared/hero-header/hero-header.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, inject, Input, NgZone, OnChanges, SimpleChanges } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { Component, inject, Input, NgZone, OnChanges, PLATFORM_ID, SimpleChanges } from '@angular/core';
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
import { injectLazy } from 'ngxtension/inject-lazy';
import { findBestMatchingImage, Image } from '../images';

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

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

private initAnimation() {
this.heroHeaderAnimation$.subscribe((animation) =>
this.ngZone.runOutsideAngular(() => animation.init()),
);
if (this.isBrowser) {
this.heroHeaderAnimation$.subscribe((animation) =>
this.ngZone.runOutsideAngular(() => animation.init()),
);
}
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/app/shared/resize-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { InjectionToken } from '@angular/core';

export type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;

const browserResizeObserverFactory: ResizeObserverFactory = (callback) =>
new ResizeObserver(callback);

export const RESIZE_OBSERVER_FACTORY = new InjectionToken<ResizeObserverFactory>(
'ResizeObserverFactory',
{
factory: () => browserResizeObserverFactory,
},
);

export const mockResizeObserverFactory: ResizeObserverFactory = () => mockResizeObserver;

const mockResizeObserver: ResizeObserver = {
observe() {},
disconnect() {},
unobserve() {},
};
49 changes: 49 additions & 0 deletions src/app/spotify-client/server-cache-store.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { inject, Injectable } from '@angular/core';
import { ICacheStore } from '@spotify/web-api-ts-sdk/src/caching/ICacheStore';
import { REQUEST, RESPONSE } from '../ssr.tokens';

// Firebase hosting only allows the `__session` cookie
// https://firebase.google.com/docs/hosting/manage-cache#using_cookies
const sessionCookieKey = '__session';

@Injectable()
export class ServerCacheStoreService implements ICacheStore {
// use "optional: true" to work with pre rendering and ng serve
// https://github.com/angular/angular-cli/issues/26323
private readonly request = inject(REQUEST, { optional: true });
private readonly response = inject(RESPONSE, { optional: true });

get(key: string): string | null {
return this.getSessionCookie()?.[key] ?? null;
}

remove(key: string): void {
const sessionCookie = this.getSessionCookie();
if (sessionCookie) {
delete sessionCookie[key];
this.saveSessionCookie(sessionCookie);
}
}

set(key: string, value: string): void {
const sessionCookie = this.getSessionCookie() ?? {};
sessionCookie[key] = value;
this.saveSessionCookie(sessionCookie);
}

private getSessionCookie(): Record<string, string> | null {
const sessionCookie = this.request?.cookies[sessionCookieKey];
if (!sessionCookie) {
return null;
}
return JSON.parse(sessionCookie);
}

private saveSessionCookie(sessionCookie: Record<string, string>) {
if (Object.keys(sessionCookie).length === 0) {
this.response?.clearCookie(sessionCookieKey);
} else {
this.response?.cookie(sessionCookieKey, JSON.stringify(sessionCookie), { maxAge: 604800000 });
}
}
}
11 changes: 9 additions & 2 deletions src/app/spotify-client/spotify-client.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
import { SpotifyAuthentication } from './spotify-authentication';
import { ICacheStore } from '@spotify/web-api-ts-sdk';
import { DOCUMENT } from '@angular/common';
import { REQUEST } from '../ssr.tokens';
import { Request } from 'express';

const CLIENT_ID = 'eda234756aae490988e32cb92412225d';

Expand All @@ -16,8 +18,13 @@ export class SpotifyClientService {
constructor(
@Inject(CACHE_STORE_TOKEN) cacheStore: ICacheStore,
@Inject(DOCUMENT) document: Document,
@Inject(REQUEST) @Optional() request: Request,
) {
const redirectUri = `${document.location.origin}/callback`;
// TODO move somewhere else
const host = request
? request.header('x-forwarded-proto') + `://` + request.header('x-forwarded-host')
: document.location.origin;
const redirectUri = `${host}/callback`;
this.spotifyAuthentication = new SpotifyAuthentication(
CLIENT_ID,
redirectUri,
Expand Down
6 changes: 6 additions & 0 deletions src/app/ssr.tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { InjectionToken } from '@angular/core';
import { Request, Response } from 'express';

// https://github.com/angular/angular-cli/issues/26110
export const REQUEST: InjectionToken<Request> = new InjectionToken<Request>('REQUEST');
export const RESPONSE: InjectionToken<Response> = new InjectionToken<Response>('RESPONSE');
7 changes: 7 additions & 0 deletions src/main.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);

export default bootstrap;
4 changes: 2 additions & 2 deletions tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": ["node"]
},
"files": ["src/main.ts"],
"files": ["src/main.ts", "src/main.server.ts", "server.ts"],
"include": ["src/**/*.d.ts"]
}

0 comments on commit 14718dc

Please sign in to comment.