Angular has adopted Universal and made it a kid of its own! Today I will rewrite some old code we used to isolated express server as we documented before with the most extreme version of serving the same build for multiple languages from Express, we are also using a standalone version as we covered recently.
Let's dig.
Find GitHub branch of cricketere with server implementation
Setup
We need to first install SSR package and remove any reference to Angular Universal. (Assuming we have upgraded to Angular 17.0)
npm install @angular/ssr
npm uninstall @nguniversal/common @nguniversal/express-engine @nguniversal/builders
Using ng add @angular/ssr
rewrites the server.ts
and it claims to add a config file somewhere. Yeah, nope!
In our last attempt for a skimmed down, standalone server.ts
, it looked like this
// the last server.ts
import { ngExpressEngine } from '@nguniversal/express-engine';
import 'zone.js/dist/zone-node';
// ...
const _app = () => bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(ServerModule),
// providers: providers array...
],
});
// export the bare minimum, let nodejs take care of everything else
export const AppEngine = ngExpressEngine({
bootstrap: _app
});
Now the ngExpressEngine
is gone. Here are three sources to dig into to see if we can create our own skimmed down engine.
- ngExpressEngine source code
- The new ssr CommonEngine source code
- Angular documentation of the ssr CommonEngine
- Express custom template engine documentation
The original Universal Express Engine received options
, created the return function, and used the CommonEngine
to render
. We can recreate a template engine with much less abstraction in our server.ts
. Here is the outcome.
Changes
- First, remember to import
zone.js
directly instead of the deep pathzone.js/dist/zone-node
- The
CommonEngine
instance can be created directly with at leastbootstrap
property set - Export the express engine function
- Get rid of a lot of abstractions
- The URL property cannot be set in Angular, it better be set in Express route.
- Use
provideServerRendering
, instead ofimportProvidersFrom
The new server.ts
now looks like this:
// server.ts
// remove nguniversal references
// import { ngExpressEngine } from '@nguniversal/express-engine';
// change import from deep to shallow:
import 'zone.js';
// add this
import { CommonEngine, CommonEngineRenderOptions } from '@angular/ssr';
// the standalone bootstrapper
const _app = () => bootstrapApplication(AppComponent, {
providers: [
// this new line from @angular/platform-server
provideServerRendering(),
// provide what we need for our multilingual build
// ... providers array
],
});
// create engine from CommonEngine, pass the bootstrap property
const engine = new CommonEngine({ bootstrap: _app });
// custom express template angine, lets call it cr for cricket
export function crExpressEgine(
filePath: string,
options: object,
callback: (err?: Error | null, html?: string) => void,
) {
try {
// grab the options passed in our Express server
const renderOptions = { ...options } as CommonEngineRenderOptions;
// set documentFilePath to the first arugment of render
renderOptions.documentFilePath = filePath;
// the options contain settings.view value
// which is set by app.set('views', './client') in Express server
// assign it to publicPath
renderOptions.publicPath = (options as any).settings?.views;
// then render
engine
.render(renderOptions)
.then((html) => callback(null, html))
.catch(callback);
} catch (err) {
callback(err);
}
};
Our Express route code, which is where the main call for all URLs are caught and processed:
// routes.js
// this won't change (it's defined by outputPath of angular.json server build)
const ssr = require('./ng/main');
// app is an express app created earlier
module.exports = function (app, config) {
// we change the engine to crExpressEngine
app.engine('html', ssr.crExpressEgine);
app.set('view engine', 'html');
// here we set the views for publicPath
app.set('views', './client');
app.get('*'), (req, res) => {
const { protocol, originalUrl, headers } = req;
// serve the main index file
res.render(`client/index.html`, {
// set the URL here
url: `${protocol}://${headers.host}${originalUrl}`,
// pass providers here, if any, for example "serverUrl"
providers: [
{
provide: 'serverUrl',
useValue: res.locals.serverUrl // something already saved
}
],
// we can also pass other options
// document: use this to generate different DOM content
// turn off inlinecriticalcss
// inlineCriticalCss: false
});
});
};
Built, Express server run, and tested. Works as expected.
A note about the URL, in a previous article we ran into an issue with reverse proxy, and had to set the the URL from a different source, as follows:
// fixing URL with reverse proxy
let proto = req.protocol;
if (req.headers && req.headers['x-forwarded-proto']) {
// use this instead
proto = req.headers['x-forwarded-proto'].toString();
}
// also, always use req.get('host')
const url = `${proto}://${req.get('host')}`;
This is better than the one documented in Angular.
Passing request and response
We cannot provide req
and res
as we did before, we used to depend on REQUEST
token from nguniversal
library. But we don't need to most of the time, we can inject the values we want from request directly into the express providers array. Here are a couple of examples: a json
config file from the server, and the server URL:
// routes.js
//...
res.render(...,
// ... provide a config.json added in express
providers: [
{
provide: 'localConfig',
useValue: localConfig // some require('../config.json')
},
{
provide: 'serverUrl',
useValue: `${req.protocol}://${req.headers.host}`;
}
]
});
Then when needed, simply inject directly
// some service in Angular
constructor(
// inject from server
@Optional() @Inject('localConfig') private localConfig: any,
@Optional() @Inject('serverUrl') private serverUrl: string
)
If however we are using a standalone function that has no constructor, like the Http interceptor function, and we need to use inject
, it's a bit more troublesome. (Why Angular?!). There are a couple of ways.
Getting from Injector
The first way is not documented, and it uses a function marked as deprecated, it has been marked for quite a while, but it is still being used under the hood. That's how I found it, by tracing my steps back to the source. Injecting the Injector
itself to get whatever is in it.
// in a standalone function like http interceptor function
export const ProjectHttpInterceptorFn: HttpInterceptorFn =
(req: HttpRequest<any>, next: HttpHandlerFn) => {
// Injector and inject from '@angular/core';
// this is a depricated
const serverUrl = inject(Injector).get('serverUrl', null);
// use serverUrl for something like:
let url = req.url;
if (serverUrl) {
url = `${serverUrl}/${req.url}`;
}
// ...
}
This is marked as deprecated.
Re-creating injection tokens
Looking at how Angular Universal Express Engine provided request and response, we get a hint of how we should do it the proper way.
We will follow the same line of thought, but let's get rid of the extra typing to keep it simple, to add Request, Response and our serverUrl
. The steps we need to go through:
- Curse Angular
- Angular: define a new injection token
- Angular consumer: inject optionally (dependency injection)
- Angular
server
file: expose new attributes for therender
function, that maps to a staticproviders
- Angular
server
file: create theproviders
function from incoming attributes - Express: pass the values in the new exposed attributes.
- Make peace with Angular.
In a new file, token.ts
inside our app, we'll define the injection tokens:
// app/token
// new tokens, we can import Request and Response from 'express' for better typing
export const SERVER_URL: InjectionToken<string> = new InjectionToken('description of token');
export const REQUEST: InjectionToken<any> = new InjectionToken('REQUEST Token');
export const RESPONSE: InjectionToken<any> = new InjectionToken('RESPONSE Token');
Then in a consumer, like the Http interceptor function, directly inject and use.
// http interceptor function
export const LocalInterceptorFn: HttpInterceptorFn = (req: HttpRequest<any>, next: HttpHandlerFn) => {
// make it optional so that it doesn't break in browser
const serverUrl = inject(SERVER_URL, {optional: true});
const req = inject(REQUEST, {optional: true});
//... use it
}
In our server.ts
we create the provider
body for our new optional tokens, and we append it to the list of providers
of the options
attribute. The value of these tokens, will be passed with renderOptions
list. Like this:
// server.ts
// rewrite by passing the new options
export function crExpressEgine(
//...
) {
try {
// we can extend the type to a new type here with extra attributes
// but not today
const renderOptions = { ...options } as CommonEngineRenderOptions;
// add new providers for our tokens
renderOptions.providers = [
...renderOptions.providers,
{
// our token
provide: SERVER_URL,
// new Express attribute
useValue: renderOptions['serverUrlPath']
},
{
provide: REQUEST,
useValue: renderOptions['serverReq']
},
{
provide: RESPONSE,
useValue: renderOptions['serverRes']
}
];
// ... render
}
};
Finally, in the Express route, we just pass the req
and res
and serverUrlpath
in render
// routes.js
app.get('...', (req, res) => {
res.render(`../index/index.${res.locals.lang}.url.html`, {
// add req, and res directly
serverReq: req,
serverRes: res,
serverUrlPath: res.locals.serverUrl
// ...
});
});
Note, I change the names of properties on purpose not to get myself confused about which refers to which, it's a good habit.
Now we make peace. It's unfortunate there isn't an easier way to collect the providers by string! Angular? Why?
Merging providers
In order not to repeat ourselves between the client and server apps, we need to merge the providers into one. Angular provides a function out of the box for that purpose: mergeApplicationConfig
(Find it here). Here is where we create a new config file for the providers list:
// in a new app.config
// in client app, export the config:
export const appConfig: ApplicationConfig = {
providers: [
// pass client providers, like LOCAL_ID, Interceptors, routers...
...CoreProviders
]
}
// in browser main.ts
bootstrapApplication(AppComponent, appConfig);
// in server.ts
// create extra providers and merge
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
]
};
const _app = () => bootstrapApplication(AppComponent,
mergeApplicationConfig(appConfig, serverConfig)
);
ApplicationConfig
is nothing but a providers
array, so I am not sure what the point is! I simply export the array, and expand it. Like this:
// app.config
// my preferred, less posh way
export const appProviders = [
// ...
];
// main.ts
bootstrapApplication(AppComponent, {providers: appProviders});
// server.ts
const _app = () => bootstrapApplication(AppComponent,
{ providers: [...appProviders, provideServerRendering()] }
);
Providing Http Cache
The transfer cache last time I checked was automatically setup and used. In this version, I did not get my API to work on the server. Something missing. Sleeves rolled up.
The withHttpTransferCacheOptions is a hydration feature. Hmmm!
Partial Hydration
The new addition is partial hydration. Let's add the provideClientHydration
to the main bootstrapper: the app.config
list of providers, that will be merged into server.ts
(it must be added to browser as well.)
// app.config
export const appProviders = [
// ...
provideClientHydration(),
];
Building, running in server, and the Http request is cached. It is a GET
request, and there are no extra headers sent. So the default settings are good enough. There was no need to add withHttpTransferCacheOptions
. Great.
So this is it. I tried innerHTML
manipulation and had no errors. I also updated Sekrab Garage website to use partial hydration and I can see the difference. The page transition from static HTML to client side was flickery. Now the hydration is smooth.
Prerendering
The last bit to fix is our prerender builder. The source code of devkit is here.
The following lines are the core difference:
// these lines in the old nguniversal
const { ɵInlineCriticalCssProcessor: InlineCriticalCssProcessor } = await loadEsmModule<
typeof import('@nguniversal/common/tools')
>('@nguniversal/common/tools');
// new line in angular/rss
const { InlineCriticalCssProcessor } = await import(
'../../utils/index-file/inline-critical-css'
);
Unfortunately we don't have access to that file (inline-critical-css). It is never exposed. Are we stuck? May be. The other solution is to bring it home (I don't like this). I am demoralized. I'll have to let go of my Angular builder, and rely on my prerender routes via express server. Which still works, no changes needed except the render
attributes, as shown above.
So there you go, thank you for reading through this, did you make peace with Angular today? 🔻
RESOURCES
- Github project Cricketere
- Angular docs for SSR caching Http
- Angular docs for partial hydration
- ngExpressEngine source code
- CommonEngine source code
- Express custom template engine
- Prerender builder devkit source code
Top comments (2)
Nice to find someone deeply interested in Angular SSR topics. Yea, Angular 17 SSR upgrade wasn't a piece of cake.
PS. and the issue github.com/angular/angular-cli/iss... is still not resolved ;)
Hi, Ayyash,
Thanks for sharing