Posted on
Last update on

Scully - a Static Site Generator for Angular series

This article is part of a growing series about Scully - a Static Site Generator for Angular. If you get excited about this article be sure to check the others!

  1. Custom plugins for Scully - Angular Static Site Generator
  2. Disabling Angular when statically generating with Scully
  3. Scully or Angular Universal, what is the difference?
  4. Scully and Angular Universal, comparing benchmarks

Target audience

In order to reach the desired performance and capabilities like proper SEO and social network shareable Angular routes you will most probably already figured out that you need the full HTML of your Angular pages delivered to the client by the server, and not just the static assets that bootstrap your Angular application in the browser.

To achieve this we currently have two notable options. Implementing Universal techniques into our Angular application, or using Scully, the static site generator for Angular. Both will give us the same result; an even more performant Angular application. But what is the difference? When to use Universal, or when to use Scully? Let's try to find out!

How does Angular Universal work?

The full textual version

In the most typical situation, Angular gets bootstrapped in the browser. Universal provides developers an alternative to bootstrap Angular in a Node.js runtime, server-side render the given Angular route and return the full HTML to the browser. We have two options. In this first case, Angular Universal is used at run-time. When using an npm script that calls a node script, we can prerender our pages with Universal at build-time.

For this to work, Node.js needs to know when the Angular application is stable. Angular is capable of knowing this by the implementation of the isStable observable on the ApplicationRef service. The isStable observable is based on the onStable observable of zone.js. Zone.js basically hooks in on the event loop and keeps track of all the micro / macro tasks that are pending.

Typical examples of these pending tasks are HTTP calls, like the fetch promise, for showing dynamic data on a page. Another example is the setTimeout or setInterval function. As soon as there are no more of those tasks pending, the onStable observable of zone.js informs the observer of the isStable observable in Angular to emit a true value.

The @angular/platform-server module exports several functions to bootstrap the Angular application at a specific route in the Node.js process. After bootstrapping the Angular application, the function starts listening on the isStable observable. When this emits a true value, the script knows that the page is rendered, and can be serialized to static HTML.

The simplified visual version

Universal step by step

The animated image shown above is a simplified visualisation of the process of server-side generating pages at run-time with Universal. Let's break down the different steps:

  1. The browser makes a request to the server for page /news/1/slug.
  2. Our Node.js server identifies these requests as a navigation request and passes the request to the Angular Universal application.
  3. The Angular app builds up the components and pages like it would normally do in the browser. Meanwhile zone.js and Angular keep track of the stability of the application.
  4. Async operations, like HTTP calls and timeouts are what make the application unstable.
  5. As soon as all operations are done and the zone becomes stable our Angular app is informed and becomes stable.
  6. We can now generate, or serialize, the HTML
  7. Our Angular Universal setup returns the full HTML to the Node.js server which returns it to the client.

Key points of consideration for Universal

  • Universal requires strict discipline in coding. You can't access the document, window, and navigator objects for example, as they do not exist on the server. This means you will have to code for the platform the code will be potentially running on. For example, accessing cookies in the browser can be done via the document object but on the server we need to read out the same cookies from the Node.js request.
  • Many 3rd party libraries access the DOM, or any of the other objects mentioned before. Some of them include intervals or timeouts that are not easy to discover. So when using them with a Universal setup, you can expect to be coding around them. To do this, you can make use of the isPlatformServer(platformId: Object) or isPlatformBrowser(platformId: Object) functions. If you don't know what you are doing, this process might be very frustrating and difficult to debug. So proceed with caution and get informed.
  • Using isPlatformBrowser or isPlatformServer is one way of solving the issue. Another approach is by using dependency injection. As you most probably will have a AppModule for the browser and a AppServerModule for the server, you can use one service that defines the interface for reading out cookies, with a class that reads out the cookies from the Node.js request on the server and another class that reads out the cookies from the document object. By providing them with useClass, you switch one out for the other, depending on the platform (Node.js or browser context).
  • Because of the Node.js server we can set up more advanced infrastructures, like adding caching layers and updating our cached pages on the fly at run-time.
  • Universal runs in a Node.js context, meaning you can also use Universal to prerender your pages to static HTML as a build-step. In this case, you don't need Node.js as a server, but only as a build tool. Your build tool will have to be clever enough to render all the required pages. In this case, Angular Universal is used at build time.
  • If you use Universal at build time, prerendering pages will take much less time than prerendering them with Scully. The cost of implementing Scully might be significantly lower though.

Important: You need to be careful with long-running tasks like setTimeout, intervals and observables that never complete. This will prevent the application from evolving to a stable state in an acceptable amount of time and potentially never become stable. If you do need for example an interval, use one of the described techniques (dependency injection or the isPlatformBrowser function) to only run those in the browser context.

How does Scully work

The full textual version

Scully is almost completely separated from your Angular application and can be configured via an external JavaScript config file. This config file, named scully.your-project-name.config.js and located at the root of your project, allows us to provide options and extra's like configuring plugins and specific ways of handling different routes.

The only direct coupling to the Angular app is the injection of the ScullyLibModule module. This module is automatically injected in the app's root module when you run the schematic ng add @scullyio/init. The module provides one important service, the IdleMonitorService, which handles the logic to inform the Scully process about the stability of the application. This concept is no different from the stability concept used at Angular Universal applications, it's just implemented slightly different.

The IdleMonitorService provides multiple ways of monitoring the state of the application. This means the service knows when the application is stable, or in this case idle. For this, the service is hooking into the routing events. When the router sends the event NavigationEnd the service checks for any pending macrotask in the zone, like for example ongoing http calls. As soon as the service believes the app is stable, the AngularReady event is triggered on the window.

When running Scully the generator first collects all the routes that need to be prerendered. This is done by a combination of smart analysis of the code, configuration, and use of plugins that use data to build a potentially very large list of dynamic routes. All those routes are loaded in parallel via a pool of instances of Chromium/Puppeteer, which is a headless browser.

For each route, the process listens to the AngularReady event. As soon as it is received, the process knows the page is stable and can be serialized to a static HTML version. Whenever there is something wrong, the process tries to generate the page again one more time before failing.

Scully also allows some configuration to manually handle the AngularReady events to gain more control over the stability or idle process. It is also possible to use configurations without zone.js, but that also implies that we have less control over the concept of stability. We have to use timeouts and waits or hook into the request mechanism of the headless browser, in this case, Puppeteer, to be sure that the page evolved into a stable state.

The simplified visual version

Scully step by step

The animated image shown above is a simplified visualisation of the process of prerendering pages at build time with Scully. Let's break down the different steps:

  1. Before running Scully you need a production build of your application. In the most typical case, this is done using the --prod flag.
  2. Next, we can start up Scully by running npm run scully. This package.json script is automatically injected with the ng add @scullyio/init schematic.
  3. The Scully process first generates the list of routes. This list can be based on dynamic data depending on the configured routes, meaning that the required data needs to be available while generating the routes. This data is most probably the same data used for generating the content on those routes. So the application or server that provides this data also needs to be running and accessible by the process.
  4. The application needs to be running to be able to prerender it. Scully starts a static webserver for your production build.
  5. Scully starts a pool of instances of Puppeteer. These shared instances of the (headless) browser will be used to generate all the pages in parallel.
  6. Next Scully starts visiting all the routes that it discovered and saved in the list of routes. The next steps are executed for all those routes.
  7. Scully instructs Puppeteer to load a specific page.
  8. The Angular app bootstraps in the (headless) browser and the navigation to the specific route is executed.
  9. After the first navigation, by checking the router events, Scully monitors the stability of the application and sends the AngularReady event.
  10. Scully now knows that it can safely instruct Puppeteer to serialize and save the full HTML of the page to the filesystem. After processing all the pages, the script is done.

Key points of consideration for Scully

  • Static pages are generated using a real browser (Chromium/Puppeteer). This means we have more support for 3rd party libraries, out of the Angular ecosystem. Those libraries can make use of the document, window, and navigator objects and there is no need to code around them like we have to do when using Angular Universal.
  • Page generation happens at build time, as compared to Angular Universal, and there is no dynamic webserver required. A static webserver is sufficient for serving all the pages and static assets. Having the pages generated at build time will dramatically increase your build time but extremely optimize your runtime. You can't use Scully in a performant way at run-time, for example server-side rendering; the pages would take too long to render and load on the server.
  • If you need server-side-rendering for applications that have highly volatile content, you should not consider Scully at this point in time. As mentioned before, regenerating those pages at run-time will just take too long.
  • Scully requires an extra build step and setup in the CI/CD process. After each page or data update the pages need to be regenerated. For a limited amount of pages, this might be ok, but we need more efficient algorithms to take care of regenerating (tens of) thousands of pages.
  • The plugin system allows you to hook into the routing and rendering process. This way we can write our own plugins for route discovery and manipulation of the rendered code. An example is to minify the generated HTML or even disable Angular on the browser completely.
  • Prerendering pages with Scully will take more time than prerendering them with Angular Universal. The cost of implementing Universal might be significantly higher though.
  • The creators behind Scully have the intention and planned features to have it work with other frameworks, like React, Vue, AngularJS, ..

Conclusion

Both Scully and Universal have their own use cases, and they tackle related problems differently. So the logical choice to make totally depends on your project, the size of it, its dependencies, and how strict you want to write Angular code and use 3rd party libraries that you can't always control.

My personal advice:

  • If you have an existing project with a relatively big codebase, perhaps using several 3rd party dependencies, and you feel that adding Universal might be a lot of work, take a few hours/days to implement Scully. You might already receive the value you need!
  • If you start from scratch, and you don't expect to rely heavily on 3rd party libraries out of the Angular ecosystem think about starting with Angular Universal. Your code will be more strict from the start and will retain higher quality while you progress in development.
  • Another important point is the number of pages. Regenerating (tens of) thousands of pages on each build/deploy might be overkill and take a long time. In this case you can consider Universal with a potential strategy to let the first request cache the static version of your page for subsequent loads. There are different strategies for warming up the cache.

As a good software architect / engineer / developer you need to check your own use case and project-specific needs. I hope you can use my observations and lessons learned to come to your own conclusion about what you need! :)

Further reading

  1. Scully - Github organization
  2. Scully - live code introduction on Youtube
  3. Scully, the First Static Site Generator for Angular
  4. Create a Static Site Using Angular & Scully
  5. Disable Angular after prerender - Scully plugin
  6. Minify the HTML of your prerendered Angular application - Scully plugin
  7. What is Cache Warming?

Special thanks to

for reviewing this post and providing valuable and much-appreciated feedback!