Angular SSR - Platform Provider Pattern
As Angular developers flock to the new features of SSR, they'll inevitably run into issues with browser specific APIs trying to run on the server. Just try to render any web components.
But not all is lost if you're strictly using Angular components. However, what about certain services that that require browser specific APIs?
Problem
Imagine there's a ClaimsDirective
that reads a JWT token. The token contains role based claims. On the browser, the JWT is read and stored into sessionStorage
. When the server tries to read the JWT from sessionStorage
the server throws an error.
PLEASE NOTE
This is merely an example using JWT tokens as a way to read claims from different storage. I'm assuming the token has been validated server side before rendering any sort of sensitive information.
Goals
The main goal is to exhibit no differences between the server side rendered markup and the client.
- Define a contract for the Client and Server
- Understand which Context the Service is Running
- Implement the JwtToken Services
- Configure the Providers
- Create ClaimsDirective
Before You Begin
A working example exists in the ngserve-io-blog-examples repository.
Define the Service Contract
The services will implement the same interface. Given the goal is to create a claims service for JWT tokens, each service includes a getClaims<T extends object>(): Nullable<T>
and a setClaims(token: string): void
.
Depending on the needs of the application using the JWT token, the storage of the token could differ between platforms. So getClaims
accepts a generic type T
.
With the interface defined, how will the application determine which instance to create to return the claims?
Understand which Context the Service is Running
Angular ships a couple of helper functions and an injection token out of @angular/common
to help determine the platform context it's running. The PLATFORM_ID
injection token's value can be supplied to the isPlatformBrowser
or the isPlatformServer
function to determine where the code is executing.
How do we choose the correct service given the platformId and inject the correct service?
Components and services dependent on platform specific services shouldn't be responsible for determining platform context and choosing the correct service to use. It'd muddy the code quickly with platform checks. The platform services they do consume should also adhere to an interface. In this case, the services will adhere to IJwtClaimsService
interface.
This is where the power of generics and the factory pattern shine. The platformProviderFactory
accepts a generic T
. Two Type<T>
parameters are supplied for the browser and server. The PLATFORM_ID
and isPlatformServer
determine the platform context and choose which service to instantiate.
How do these services differ and for JWT tokens returning claims?
Implement the JWT Token Services
Reading the token is dependent where it's stored. The JwtToken services differ on which platform specific storage service to use.
Parsing the claims from the token should be consistent for both services. The parseClaims
function ensures the token is split correctly and returns the payload of the JWT. It can be used by both the server and browser services we define later.
The browser JwtToken service stores the JWT into session storage and implements the IJwtClaimsService
interface. The claims are read and parsed from the session storage key JWT_TOKEN
.
The server JwtToken is read from the headers in a cookie. This implementation requires the request to be passed to the service. Injection tokens REQUEST
/ RESPONSE
pass an express
request / response to the CookieJwtService
.
The CookieJwtService
implements the IJwtClaimsService
. The claims are read from the JWT_TOKEN
cookie on the request and parsed into the generic T
claims object.
Configure the Providers
Using the platformFactoryProvider
function created earlier, the jwtClaimsService
function defines the browser and server claims services. This is the only function exported out of this library. Developers won't need to worry about which service to choose. The jwtClaimsService
can be invoked in any injection context.
The server context needs to pass along the express request object. Using the REQUEST
/ RESPONSE
injection tokens, the CommonEngine
requires these providers be injected into CookieJwtClaimsService
.
The jwtClaimsService
can be injected into any component or service requiring claims from the JWT.
Create the ClaimsDirective
It could be argued the app doesn’t need to be server side rendered behind an authorized route. But why make the browser do the work the server can do from the beginning? Even authorized routes that are server side rendered make a better more performant experience.
The ClaimsDirective
accepts a role checking if the role exists in the claims. The jwtClaimsService
function provides us the correct service to use to retrieve the claims. Using the getClaims
method, the claims are returned and the role is compared against the directive's input.
Determining which block of code to show the user, the ClaimsDirective
is applied showing which role the user belongs.
If working in the ngserve-io-blog-examples repository, start the sample application by running the following commands.
npx nx build platform-provider-pattern-app
node ./dist/apps/platform-provider-pattern-app/server/server.mjs
A login page shows initially. Click the login button as this will generate a sample token with claims and add the token to session storage. The page shows the claims in the page including the I am an Admin
text per the role.
Conclusion
The global api surface will differ base on the platform the code runs. Server side render relies on the request headers for handling requests. In this case, a HttpOnly cookie is unavailable for the browser to read. The sessionStorage is unavailable for Node to read. Our code needs to understand where to read the JWT token.
Determining storage mechanism to be used in the platform can done through using the isPlatformBrowser function. Without littering the code with this function, the platformServiceProvider
registers and chooses which service to use with a common interface.