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 Jul 28, 2024
1 parent b13e298 commit 94f9f7d
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 16 deletions.
7 changes: 6 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
"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 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();
17 changes: 17 additions & 0 deletions src/app/app.config.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
import { ServerSessionStorage } from './spotify-client/server-session-storage.service';
import { SessionStorage } from './spotify-client/session-store.service';

const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
{
provide: SessionStorage,
useClass: ServerSessionStorage,
},
],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);
18 changes: 14 additions & 4 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +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 { BrowserSessionStorage } from './spotify-client/browser-session-storage.service';
import { SessionStorage } from './spotify-client/session-store.service';
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
providers: [
provideAnimationsAsync(),
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })),
provideHttpClient(withInterceptors([authenticationInterceptor])),
provideRouter(
routes,
withRouterConfig({ onSameUrlNavigation: 'reload' }),
withEnabledBlockingInitialNavigation(),
),
provideHttpClient(withFetch(), withInterceptors([authenticationInterceptor])),
{
provide: SessionStorage,
useClass: BrowserSessionStorage,
},
provideClientHydration(),
],
};
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
31 changes: 31 additions & 0 deletions src/app/spotify-client/server-session-storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { inject, Injectable } from '@angular/core';
import { REQUEST, RESPONSE } from '../ssr.tokens';
import { SessionStorage } from './session-store.service';

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

@Injectable()
export class ServerSessionStorage extends SessionStorage {
// 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 });

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

public saveSessionData(sessionCookie: Record<string, string>) {
if (Object.keys(sessionCookie).length === 0) {
this.response?.clearCookie(sessionCookieKey);
} else {
this.response?.cookie(sessionCookieKey, JSON.stringify(sessionCookie), { maxAge: 604800000 });
}
}
}
16 changes: 13 additions & 3 deletions src/app/spotify-client/spotify-client.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Inject, Injectable } from '@angular/core';
import { Inject, Injectable, Optional } from '@angular/core';
import { SpotifyAuthentication } from './spotify-authentication';
import { DOCUMENT } from '@angular/common';
import { SessionStoreService } from './session-store.service';
import { SpotifyTokenApiService } from './spotify-token-api.service';
import { REQUEST } from '../ssr.tokens';
import { Request } from 'express';

const CLIENT_ID = 'eda234756aae490988e32cb92412225d';
const SCOPES = ['user-top-read'];
Expand All @@ -13,8 +15,16 @@ const SCOPES = ['user-top-read'];
export class SpotifyClientService {
private readonly spotifyAuthentication: SpotifyAuthentication;

constructor(sessionStoreService: SessionStoreService, @Inject(DOCUMENT) document: Document) {
const redirectUri = `${document.location.origin}/callback`;
constructor(
sessionStoreService: SessionStoreService,
@Inject(DOCUMENT) document: Document,
@Inject(REQUEST) @Optional() request: Request,
) {
// 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(
new SpotifyTokenApiService(CLIENT_ID, redirectUri),
sessionStoreService,
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 94f9f7d

Please sign in to comment.