DEV Community

Cover image for Replacing Angular Universal with SSR version 17.0
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Replacing Angular Universal with SSR version 17.0

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
});
Enter fullscreen mode Exit fullscreen mode

Now the ngExpressEngine is gone. Here are three sources to dig into to see if we can create our own skimmed down engine.

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 path zone.js/dist/zone-node
  • The CommonEngine instance can be created directly with at least bootstrap 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 of importProvidersFrom

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

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')}`;
Enter fullscreen mode Exit fullscreen mode

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}`;
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 the render function, that maps to a static providers
  • Angular server file: create the providers 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');
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
};
Enter fullscreen mode Exit fullscreen mode

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
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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()] }
);
Enter fullscreen mode Exit fullscreen mode

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(),
];
Enter fullscreen mode Exit fullscreen mode

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'
);
Enter fullscreen mode Exit fullscreen mode

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

RELATED POSTS

Replacing Angular Universal with SSR version 17.0 - Sekrab Garage

Angular SSR Update. 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 th.... Posted in Angular

favicon garage.sekrab.com

Top comments (2)

Collapse
 
krisplatis profile image
Krzysztof Platis

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 ;)

Collapse
 
jangelodev profile image
João Angelo

Hi, Ayyash,
Thanks for sharing