DEV Community

Ihor Filippov
Ihor Filippov

Posted on

Angular SSR with vanilla Node.js

Introduction

Hello! Last time we was talking about Angular Universal boilerplate setup. And today, we will also talk about angular universal tuning, but without already baked libraries like express and ngExpressEngine. Only vanilla Node.js, only hardcore :)

I suppose this article will be useful for developers who want to have a deeper understanding how to setup angular application at server side or to connect angular with web servers which is not represented in official repo

Let's go!

I assume that your already have @angular/cli installed.

We will start from scratch. First create a new project:

ng new ng-universal-vanilla
cd ng-universal-vanilla
Enter fullscreen mode Exit fullscreen mode

Then run the following CLI command

ng add @nguniversal/express-engine
Enter fullscreen mode Exit fullscreen mode

Actually, we do not need express web server. But we need a plenty of another files produced by this command.

First of all, take a look at server.ts file. At the line 18, you can find ngExpressEngine. This is the heart of angular server side rendering. It is express-based template engine, which use angular universal CommonEngine under the hood. And CommonEngine it is exactly, what we need.

In the root directory create ssr/render-engine/index.ts file, with few lines of code in it:

import { ɵCommonEngine as CommonEngine, ɵRenderOptions as RenderOptions } from "@nguniversal/common/engine";
import { readFileSync } from "fs";

const templateCache = {};

export function renderEngine() {
  const engine: CommonEngine = new CommonEngine();

  return async function (filepath: string, renderOptions: RenderOptions) {
    try {
      if (templateCache[filepath]) {
        renderOptions.document = templateCache[filepath];
      } else {
        renderOptions.document = readFileSync(filepath).toString();
        templateCache[filepath] = renderOptions.document;
      }

      return await engine.render(renderOptions);

    } catch (err) {
      throw new Error(err);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A renderEngine function creates an instance of CommonEngine and returns another function which mission is to run angular bundle in server/main.js and produce an html template. In addition, we use a templateCache to store the index.html source code for better performance.
With this approach, we able not to run the synchronous readFile function any time when server receives a request from the browser. Now, go to the server.ts file, remove everything from it and add following lines:

import "zone.js/dist/zone-node";
import { createServer, IncomingMessage, ServerResponse, Server } from "http";
import { AppServerModule } from "./src/main.server";
import { APP_BASE_HREF } from "@angular/common";
import { join } from "path";
import { renderEngine } from "./ssr/render-engine";

const browserFolder: string = join(process.cwd(), "dist/ng-universal-vanilla/browser");
const indexTemplate: string = join(browserFolder, "index.html");
const port = process.env.PORT || 4000;

const renderTemplate = renderEngine();

const app: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {

  const html = await renderTemplate(indexTemplate, {
    url: `http://${req.headers.host}${req.url}`,
    bootstrap: AppServerModule,
    providers: [
      { provide: APP_BASE_HREF, useValue: "/" },
    ]
  });

  res.writeHead(200);
  res.end(html);
});

app.listen(port, () => console.log(`Server is listening at ${port} port`));
Enter fullscreen mode Exit fullscreen mode

The code is almost same as it was before deleting. But instead of express web server we added our renderEngine which we wrote earlier and some stuff from http Node.js module to create a web server. Now, run the following commands and open your browser at http://localhost:4000

npm run build:ssr
npm run serve:ssr
Enter fullscreen mode Exit fullscreen mode

If you did everything right you should see an Angular welcome page. We did it! We generated an angular template and sent it to the browser. But, to say the truth it is not enough for full server operation. If you open a developer tools console, you will see this message:
Static files not served
This happens because we are sending html, but not serving our static files which lay in the index.html file. We have to update our server.ts file a little bit:

..other imports
import { readFile } from "fs";

const browserFolder: string = join(process.cwd(), "dist/ng-universal-vanilla/browser");
const indexTemplate: string = join(browserFolder, "index.html");
const port = process.env.PORT || 4000;

const renderTemplate = renderEngine();

const app: Server = createServer((req: IncomingMessage, res: ServerResponse) => {

  const filePath: string = browserFolder + req.url;

  readFile(filePath, async (error, file) => {
    if (error) {
      const html = await renderTemplate(indexTemplate, {
        url: `http://${req.headers.host}${req.url}`,
        bootstrap: AppServerModule,
        providers: [
          { provide: APP_BASE_HREF, useValue: "/" },
        ]
      });
      res.writeHead(200);
      res.end(html);
    } else {
      if (req.url.includes(".js")) {
        res.setHeader("Content-Type", "application/javascript")
      }

      if (req.url.includes(".css")) {
        res.setHeader("Content-Type", "text/css");
      }

      res.writeHead(200);
      res.end(file);
    }
  });

});

app.listen(port, () => console.log(`Server is listening at ${port} port`));
Enter fullscreen mode Exit fullscreen mode

We imported a readFile function from node.js built-in module fs. On each request we try to read a file in the dist/ng-universal-vanilla/browser folder. If it exists, we send it to the browser.

Content-type header is also important, without it browser will not know in what manner handle our .css or .js file. If file is not exist, readFile function throws an error and we know that this url should be rendered by angular universal engine. Of course, at first look, handling of angular templates with error condition looks weird, but even node.js official docs recommend this approach instead of checking with fs.acess function.

HINT: In real application, your static files will be served with something like Nginx or Apache. This approach, is only for demonstration of angular universal engine with vanilla node.js server

Now, run the following commands and reload the page.

npm run build:ssr
npm run serve:ssr
Enter fullscreen mode Exit fullscreen mode

Our angular application is ready to go!

Handling cookies and DI provider

In next few lines, I want to show how to deal with cookies with vanilla node.js server and how to provide a request object to angular application.

First of all, we need to create an injection token for request object, which can be used later in a DI provider.
Create ssr/tokens/index.ts file and add a following code

import { InjectionToken } from "@angular/core";
import { IncomingMessage } from "http";

export declare const REQUEST: InjectionToken<IncomingMessage>;
Enter fullscreen mode Exit fullscreen mode

Then, provide it in the renderTemplate function in server.ts file

...
import { REQUEST } from "./ssr/tokens";
...
const html = await renderTemplate(indexTemplate, {
  url: `http://${req.headers.host}${req.url}`,
  bootstrap: AppServerModule,
  providers: [
    { provide: APP_BASE_HREF, useValue: "/" },
    { provide: REQUEST, useValue: req },
  ]
});
...
Enter fullscreen mode Exit fullscreen mode

That's almost all. We prepared our request injection token, and now can use it.
Open app.server.module.ts and update it like this

import { NgModule, Inject, Injectable, Optional } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { REQUEST } from "../../ssr/tokens";
import { IncomingMessage } from "http";

@Injectable()
export class IncomingServerRequest {
  constructor(@Inject(REQUEST) private request: IncomingMessage) { }

  getHeaders() {
    console.log(this.request.headers, "headers");
  }
}

@NgModule({
  imports: [
    AppModule,
    ServerModule,
  ],
  bootstrap: [AppComponent],
  providers: [
    { provide: "INCOMING_REQUEST", useClass: IncomingServerRequest },
  ]
})
export class AppServerModule {
  constructor(@Optional() @Inject("INCOMING_REQUEST") private request: IncomingServerRequest) {
    this.request.getHeaders();
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, we created and provided a standalone class IncomingServerRequest which have our request object injected and it is ready to use.

Again, build and run our app

npm run build:ssr
npm run serve:ssr
Enter fullscreen mode Exit fullscreen mode

In the console of our web server you should see a list of headers related to a request from your browser.

What about cookies?

First we have to extend a request object annotations. So, in the ssr/models/index.ts file add this code:

import { IncomingMessage } from "http";

export interface IncomingMessageWithCookies extends IncomingMessage {
  cookies: {[key: string]: string};
}
Enter fullscreen mode Exit fullscreen mode

Now, we can add a new property to our request object without conflicts in typescript. To parse cookies, install a cookie package from npm.

npm i --save cookie
Enter fullscreen mode Exit fullscreen mode

then update a server.ts file a little bit

...
import { parse } from "cookie";

...

const app: Server = createServer((req: IncomingMessageWithCookies, res: ServerResponse) => {

  const filePath: string = browserFolder + req.url;

  readFile(filePath, async (error, file) => {
    if (error) {    

      req.cookies = parse(req.headers.cookie);

      const html = await renderTemplate(indexTemplate, {
        url: `http://${req.headers.host}${req.url}`,
        bootstrap: AppServerModule,
        providers: [
          { provide: APP_BASE_HREF, useValue: "/" },
          { provide: REQUEST, useValue: req },
        ]
      });
      res.writeHead(200);
      res.end(html);
    } else {
      if (req.url.includes(".js")) {
        res.setHeader("Content-Type", "application/javascript")
      }

      if (req.url.includes(".css")) {
        res.setHeader("Content-Type", "text/css");
      }

      res.writeHead(200);
      res.end(file);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

and a app.server.module.ts

...
import { IncomingMessageWithCookies } from "../../ssr/models";

@Injectable()
export class IncomingServerRequest {
  constructor(@Inject(REQUEST) private request: IncomingMessageWithCookies) { }

  getHeaders() {
    console.log(this.request.headers, "headers");
  }

  getCookies() {
    console.log(this.request.cookies)
  }
}

@NgModule({
  imports: [
    AppModule,
    ServerModule,
  ],
  bootstrap: [AppComponent],
  providers: [
    { provide: "INCOMING_REQUEST", useClass: IncomingServerRequest },
  ]
})
export class AppServerModule {
  constructor(@Optional() @Inject("INCOMING_REQUEST") private request: IncomingServerRequest) {
    this.request.getHeaders();
    this.request.getCookies();
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, do not forget to update a ssr/tokens/index.ts file

import { InjectionToken } from "@angular/core";
import { IncomingMessageWithCookies } from "../models";

export declare const REQUEST: InjectionToken<IncomingMessageWithCookies>;
Enter fullscreen mode Exit fullscreen mode

And that's it! Now we have an angular application with server side rendering setup, without express and other frameworks.

I hope this article was useful for you.

P.S. Source code can be found at github .

Top comments (1)

Collapse
 
lindyw profile image
Woon Him WONG • Edited

Thank you, this is very useful.
But I am stuck at the last part getting header and cookies.

The export declare constant REQUEST wasn't able to import when I ran npm run build:ssr

./server.ts:36:23-30 - Error: export 'REQUEST' (imported as 'REQUEST') was not found in './ssr/tokens' (module has no exports)
 ./src/app/app.server.module.ts:17:125-132 - Error: export 'REQUEST' (imported as 'REQUEST') was not found in '../../ssr/tokens' (module has no exports)
Enter fullscreen mode Exit fullscreen mode

It seems okay only when I tried to change it to a simple string token only

InjectionToken<IncomingMessageWithCookies>
Enter fullscreen mode Exit fullscreen mode

to

InjectionToken<string>
Enter fullscreen mode Exit fullscreen mode

and assign = new InjectionToken('xxx');