DEV Community

loading...

Angular SSR with vanilla Node.js

igorfilippov3 profile image Igor Filippov ・6 min read

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

Then run the following CLI command

ng add @nguniversal/express-engine

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

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

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

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

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

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

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 },
  ]
});
...

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

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

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

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

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

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

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

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 .

Discussion (0)

pic
Editor guide