Development of an isomorphic RealWorld application with SSR and Progressive Enhancement. Part 2 - Hello World

Published on February 23, 2018

Development of an isomorphic RealWorld application with SSR and Progressive Enhancement. Part 2 - Hello World

  • Tutorial
In the previous part of the tutorial, we learned what the RealWorld project is , determined the goals of the tutorial, selected a technology stack and wrote a simple Express web server as the basis for an isomorphic frontend.

In this part, we will finish the server part and write isomorphic “Hello World” on Ractive , and we will also collect all this using Webpack .



Thanks in advance to all those who continue to read this tutorial! If you are really seriously interested in the topic of universal web applications, then you should also read a series of articles on the same topic, "React + Express Universal Applications . " This will be especially interesting for those who like to write a lot of code (I do not).

Disclaimer
Данный туториал предназначен прежде всего для frontend-разработчиков среднего и выше уровня. Которые знакомы с современными инструментами разработки и в курсе, что такое SPA и изоморфность.

В рамках туториала НЕ будут раскрываться вопросы установки npm-модулей, вводного знакомства с webpack, работы с командной строкой или иных базовых вещей на сегодняшний день. Исхожу из того, что для большинства читатетей рутинные операции по настройки дев-среды и работы с инструментами разработки и т.п. уже знакомы и отлажены.

We finish the server




Request Proxy


So, based on the selected "high-level" architecture described in the first part, it is necessary to organize proxying requests to the backend server through the frontend.

Why so
В комментариях к первой части туториала, был задан резонный вопрос — зачем проксировать запросы на бекенд? С точки зрения изоморфного подхода это, вобщем-то, не обязательно. Особенно если вы любите трудности и решение нестандартных задач, неочевидными способами.

Однако практика показывает, что именно этот способ является наиболее быстрым, удобным и безболезненным решением целого ряда вопросов и проблем изоморфности, о которых речь пойдет дальше.

Кроме того, проксирование открывает некоторые дополнительные возможности:

  • Перехват клиентских атак, типа XSS/CSRF/etc.;
  • Сокрытие бекенд сервера + нет необходимости включать CORS на бекенде;
  • Возможности изоморфного кэширования данных и запросов;
  • Более безопасная работа с сессиями/токенами/итп;
  • Перехват запросов/ответов и внесение точечных изменений;
  • Точечная адаптация API под нужны клиента;
  • Изменение способов авторизации;
  • Упрощенная реализация асинхронного «stateful» функционала поверх синхронных «stateless» бекендов;
  • Работа поверх нескольких бекендов (или микросервисов);

И т.д. и т.п.

To do this, we first study the specification for the REST API of the RealWorld project . The API itself is located at: conduit.productionready.io/api

Since I don’t like to write extra code, so I won’t invent bicycles and use the express-http-proxy module . This module can not only proxy http-requests, but also provides a number of hooks for the various stages of this process, which, obviously, will still be useful to us.

First, we will write a simple json-config for our API, where we define the URL where to proxy, as well as some additional settings.

./config/api.json

{
    "backendURL": "https://conduit.productionready.io",
    "timeout": 3000,
    "https": true
}

“Https”: true means that even if the original request was made via http, it needs to be proxied to https. Convenient when working with localhost.

In the first part, I prepared a special “api middleware” for proxying requests. It's time to write it:

./middleware/api.js

const proxy = require('express-http-proxy');
const config = require('../config/api.json');
module.exports = () => (req, res, next) => {
    proxy(config.backendURL, {
        https: config.https,
        timeout: config.timeout
    })(req, res, next);
};

Perhaps this is enough for now. Already, all requests to the / api / * frontend will be proxied to the backend server. In other words, if you request GET / api / articles from the front-end server , the response will return JSON of the form:

{
  "articles":[...],
  "articlesCount": 100
}

Since we plan to work not only with GET requests, but also with all possible REST verbs (POST / PUT / DELETE), as well as execute requests without JS on the client (i.e. using html forms), it is also necessary make a couple of changes to the main web server file from the first part:

./server.js

const methodOverride = require('method-override');

server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));

Please note that we will parse the request body both in json (via ajax) and urlencoded (form submission). The method-override module will overwrite the http request method with the one specified in the _method parameter in the special Query URL . This is due to the fact that html forms only support GET and POST methods. It will work something like this:

<form action="/something?_method=PUT" method="POST">
.....
</form>

If JS is disabled on the client and the form has been submanned, then this module will automatically replace the original POST for PUT for us and the proxy will receive the correct REST verb for further proxying. Simple and without straining.

About rewriting the http method
Интересный факт заключается в том, что некоторые «гуру» RESTful сервисов советуют использовать подобный query-параметр при проектировании REST API на бекендах. Ну типа, мы же хотим подумать и о тех клиентах нашего API, которые поддерживают лишь ограниченный список HTTP методов.

Однако, по факту, это актуально лишь для браузерных html-форм. В этом случае, не очень разумно осуществлять поддержку столь узкого кейса на бекенде, который используется различными типами клиентов. Большая часть из которых не нуждается в данной фиче. В то же время, использование этой техники в рамках фронтенд-севера вполне себе обосновано, а главное не затрагивает интересы других типов клиентов. Вот так вот.

Full server.js code
const express = require('express'),
      helmet = require('helmet'),
      compress = require('compression'),
      cons = require('consolidate'),
      methodOverride = require('method-override');
const app = require('./middleware/app'),
      api = require('./middleware/api'),
      req = require('./middleware/req'),
      err = require('./middleware/err');
const config = require('./config/common');
const server = express();
server.engine('html', cons.mustache);
server.set('view engine', 'html');
server.use(helmet());
server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));
server.use(compress({ threshold: 0 }));
server.use(express.static('dist'));
server.use(req());
server.all('/api/*', api());
server.use(app());
server.use(err());
server.listen(config.port);


As a result, we have a complete proxy of requests for the backend, as well as support for requests from html forms.



Initial state problem


I want to make a reservation right away, here by “initial state” I mean not the initial state of the application data, but a set of input parameters with which the application should be launched. For each specific project, such a set of parameters will be different, or may be absent altogether. Base case - presence or absence of authorization.

Most likely, the initial state was set by your user during the previous work session (for example, an account is logged in) and after the user has returned, he expects to see the application with this initial state (logged in).

The standard SPA is first loaded onto the page, scripts are launched, and then requests for data are already made. At this point, the script can get the initial state parameters based on the browser data or something else, and only then make requests to the API.

Unlike SPA, an isomorphic application does not have any, relatively speaking, “bootloader”. The isomorphic application in this part is more like an ordinary website. In other words, the initial state must be obtained at the time of the first synchronous request, so that the page rendered on the server fully matches the state that the user expects. Of course, there are times when developers are lazy and then we see the server render the page with some kind of default state, then the scripts on the client are launched and the client does all the work again. This is not the right approach, but in the manifesto of this project it is clearly written - no crutches (item 7)!

This question is being solved, perhaps, by the only way available at the moment - with the help of cookies (I think many immediately guessed when they saw the poster). Yes, indeed, cookies are actually the only official way to transfer the initial state between several synchronous requests to the server. Of course, when it comes to the browser.

Well, since we well understand that in terms of storage, cookies are not very suitable (only 4Kb), and most importantly, we don’t yet understand which parameters of the initial state we will eventually have to use, the hand goes to sessions! Thus, for ordinary sessions, when a certain session_id (sid) is written to the cookie, and a whole pile of data associated with this identifier is stored on the server.

I think in general terms it’s clear why we need it and, again, I won’t come up with anything here. I’ll take a classic express-session , I’ll stuff it with parameters a little and we’ll get quite a working mechanism for ourselves without unnecessary difficulties.

Add the “session” object with the settings for the module to the main config.

./config/common.json

{
    "port": 8080,
    "session": {
        "name": "_sid",
        "secret": "ntVA^CUnyb=6w3HAgUEh+!ur4gC-ubW%7^e=xf$_G#wVD53Cgp%7Gp$zrt!vp8SP",
        "resave": false,
        "rolling": false,
        "saveUninitialized": false,
        "cookie": {
            "httpOnly": true,
            "secure": false,
            "sameSite": true
        }
    }
}

A few key settings for our cookie - you should always set httpOnly and sameSite . When switching to SSL, you can also activate secure (the cookie will only be sent when working through https).

Add this module to the web server file:

./server.js

const session = require('express-session');

server.use(session(config.session));

Full server.js code
const express = require('express'),
      helmet = require('helmet'),
      compress = require('compression'),
      cons = require('consolidate'),
      methodOverride = require('method-override'),
      session = require('express-session');
const app = require('./middleware/app'),
      api = require('./middleware/api'),
      req = require('./middleware/req'),
      err = require('./middleware/err');
const config = require('./config/common');
const server = express();
server.engine('html', cons.mustache);
server.set('view engine', 'html');
server.use(helmet());
server.use(session(config.session));
server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));
server.use(compress({ threshold: 0 }));
server.use(express.static('dist'));
server.use(req());
server.all('/api/*', api());
server.use(app());
server.use(err());
server.listen(config.port);


Now, among other things, we have a mechanism for saving and transmitting the initial state of the application between several synchronous requests. Since this task entirely follows from the features of working with a browser, a front-end server is the best place for such things. If we did such things on the backend, this could affect other types of clients (for example, mobile) for which there is no need for such tricks. This is one of the advantages of universal web applications - we get a secure environment (front-end server) in which we can perform front-end tasks without compromising security and without need to “clog” the backend with such things. This is especially true when the backend works with many types of clients.

Hello world




Well, so far it looks pretty simple, but hell, you say, where is the isomorphism? Everything will be, but for starters, let's deal with such a part as Server-side rendering (SSR) .

To get started, let's write a simple isomorphic “hello world” using RactiveJS .

./src/app.js

const Ractive = require('ractive');
Ractive.DEBUG = (process.env.NODE_ENV === 'development');
Ractive.DEBUG_PROMISES = Ractive.DEBUG;
Ractive.defaults.enhance = true;
Ractive.defaults.lazy = true;
Ractive.defaults.sanitize = true;
const options = {
    el: '#app',
    template: `<div id="msg">Static text! + {{message}} + {{fullName}}</div>`,
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User'
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    }
};
module.exports = () => new Ractive(options);

This file is the entry point to our isomorphic application. Let's look at it in more detail.

RactiveJS exports the constructor of the same name with which you can create Ractive instances, as well as Ractive components (more on this in the next sections). VueJS may remind many of them of this approach, and this is no accident . In fact, Ractive is one of the prototypes for Vue and their api are still very similar.

But back to the code, and first, we will figure out which static properties of the constructor I set for this application. The first is Ractive.DEBUG and Ractive.DEBUG_PROMISESwith the help of which we enable or disable informational error messages depending on the current environment.

Next comes the Ractive.defaults.enhance flag , which activates one of the key aspects of isomorphism - reusing markup obtained as a result of SSR on the client side. This is now most often called the obscure term hydrate .

Hydrate theory
Если по-простому, то фишка в том, что после того, как приложение инициализируется на клиенте оно может «захотеть» перерендерить всю разметку взамен той разметки, которая пришла с сервера (SSR). Не то чтобы это супер плохо для изоморфности — поддержку SEO и многие другие плюшки мы все равно получаем. Однако в любом случае это крайне нерационально.

Поэтому важно не просто уметь делать SSR (это сейчас умеют многие). Хорошо еще когда ваш фреймворк умеет делать эту самую «гидрацию», т.е. может проанализировать текущую разметку и данные, чтобы понять, что результатом повторного рендера приложения или отдельных его компонентов будет та же самая разметка, а значит делать этого не нужно (либо нужно, но частично). Далее, просто «оживить» существующую разметку, т.е. «навесить» все необходимые ивент-лисенеры, хендлеры или чего там еще ему надо.

Все это с относительно недавнего времени умеют все представители «большой тройки». Ractive научился этому еще раньше, именно поэтому использует свой собственный термин «enhance», вместо введенного реактом «hydrate». Просто не было тогда еще такого термина)))

Ractive.defaults.enhance = true;

By default, this flag is set to false and this line of code activates this feature immediately for all Ractive components . In other words, with one line of code you can make your Ractive application reuse markup from the server. At the same time, if any particular component suddenly does not require "hydration", it can be disabled locally through its options .

Finally, two more flags that I always set:

  • Ractive.defaults.sanitize allows cutting unsafe html tags at the stage of parsing templates .
  • Ractive.defaults.lazy tells the framework to use late DOM events (change, blur), instead of immediately executing (keyup, keydown) for two-way bindings (yes, double binding drives).

About two-way bindings
Всех «two-way bindings» хейтеров и «one-way data flow» ловеров попрошу воздержаться от холивара в комментариях. Это не вопрос религии. Если вы считаете, что лично для вас двойное-связывание данных представляет угрозу, то не используйте его и будете правы. Никогда не нужно зазря подвергать себя опасности.

В своих приложениях я, как правило, активно использую двойное связывание там где это необходимо и удобно, и пока не испытываю с этим каких-то серьезных проблем. Благо Ractive не сильно религиозный фреймверк и не заносит в разработчика какие-то свои собственные морально-этические принципы. Подход, который использует Ractive отлично описан на главной странице его сайта:
Unlike other frameworks, Ractive works for you, not the other way around. It doesn't have an opinion about the other tools you want to use with it. It also adapts to the approach you want to take. You're not locked-in to a framework-specific way of thinking. Should you hate one of your tools for some reason, you can easily swap it out for another and move on with life.
Если вы ненавидите или боитесь двойного связывания, данная проблема решается в Ractive одной строкой:

Ractive.defaults.twoway = false;

После этого все ваши компоненты потеряют возможность двойного связывания, если конечно вы не захотите включить его для какого-то конкретного компонента (twoway: true в опциях) или же даже конкретного поля для ввода (деректива twoway=«true»). Как правило, это удобно.

Interesting case with lazy
Не смог удержаться. Дело в том, что lazy может применяться не только глобально и локально для каждого компонента, но и точечно в качестве директивы поля для ввода. Кроме того, lazy может принимать не только Boolean, но также число — кол-во миллисекунд для задержки.

С помощью этого замечательного свойства, можно, например, очень быстро и лаконично решить одну из часто встречающихся задач веб-разработки — строка поискового запроса:

<input type="search" value="{{q}}" lazy="1000"/>

Рабочий пример

As a result, I form an object with options for the application instance. Where I inform which existing DOM element to render the application ( el ), I define some kind of test template ( template ), so far as a string. I define an object with data ( data ) and one computed property ( fullName ).

It looks pretty dumb, because for now we only write “Hello world” and its purpose is to test SSR and “hydration” on the client. I'll tell you later how we can verify this.

Now we’ll write the server “app middleware” , which includes all requests that do not fall into the proxy.

./middleware/app.js

const run = require('../src/app');
module.exports = () => (req, res, next) => {
    const app = run();
    const meta = { title: 'Hello world', description: '', keywords: '' },
        content = app.toHTML(),
        styles = app.toCSS();
     app.teardown();
     res.render('index', { meta, content, styles });
};

Everything is straightforward here. If you notice, the main application file (./src/app.js) exports a function that returns a new Ractive instance, i.e. essentially a new application object. This is not too important when the code is executed on the client - we most likely will not create more than one application instance within the tab. However, to execute code on a “stateful” nodejs server, it is extremely important to be able to create a new application object for each synchronous request. I think this is obvious.

So, at the time of the request, we create a new application object. We create an object with meta tags (still static), and then 2 lines of code with the notorious SSR :

  • app.toHTML () - renders the current state of the application into a string;
  • app.toCSS () - collects all "component specific" styles that are already broken by namespace and also returns them as a string.

So simple it works in Ractive . And yes, the functionality of component styles is already out of the box.

Next, I destroy the current application instance by calling the teardown () method and render the server template "./views/index.html" with the received values, while sending a response. That's the whole great and terrible SSR .


Server Templates


Now a little "mustache". So, we have the “./views” folder where the server templates will lie and we expect in it the very “single-page” index.html in which our wonderful isomorphic application will be rendered. However, we will not write index.html .

This is because the final client bundles generated by Webpack must be registered during the build with its help. Therefore, you have to create a template for the template ! ;-)

./views/_index.html

<!doctype html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="description" content="{{ meta.description }}">
        <meta name="keywords" content="{{ meta.keywords }}"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
        <title>{{ meta.title }}</title>
        <link rel="stylesheet" href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css">
        <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic">
        <link rel="stylesheet" href="//demo.productionready.io/main.css">
        <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
        <link rel="icon" type="image/png" href="/img/favicon.png">
        <link rel="apple-touch-icon" href="/img/favicon.png">
        <link rel="manifest" href="/manifest.json">
        <style>
            {{& styles }}
        </style>
    </head>
    <body>
        <div id="app">
            {{& content }}
        </div>
        <script>
            window.msgEl = document.getElementById('msg');
        </script>
    </body>
</html>

There is nothing special here either, just a full HTML file, with doctype, all sorts of meta tags, links to style files and fonts that RealWorld provides and so on. Here we see how the title , description and keywords meta tags will be templated . Also note that component styles are simply placed on the page in a style tag. And of course, our rendered application is placed in an element with the corresponding identifier. It is in this html element that the application will search and “hydrogenate” the markup on the client (the very #app tag , which is usually empty with the classic SPA approach)

After building the client code, Webpackit will write to the end of this file (immediately before the closing body tag ) all bundle files in the connection sequence and with the names provided by the project and its config, and also generate the final index.html (more on that in the next section). That's how it is somehow.



Webpack


I promised that I would not use any specific tools for writing an isomorphic application. The same applies to Webpack and its configs.

Therefore, I just take the existing webpack config that I used for the previous demo project (more in the first part) and will use it in the form as it is. Moreover, this config also got into that demo project from some other project and was practically not finalized. There were even rudiments that were essentially not needed in these projects, but they did not interfere and I was too lazy to cut them. It works and okay.

Unlike the vast majority of tutorials and starter kits on an isomorphic topic, I will not write separate webpack configs for the client and server. Moreover, I will not build bundles for the server at all. All application files on the server will work without any manipulation with them.

Why is that
Краткий ответ: для сервера сборка не имеет практического смысла.

Если более подробно: мне часто приходится работать с множеством всевозможных окружений и единственное из них, которое полностью находится под моим контролем — это фронтенд-сервер. Поэтому я привык писать свой код исходя из возможностей подконтрольного окружения и на сервере мне не требуются всевозможные транспайлеры, минификация кода и уж тем более сборка кода в бандлы. Однако, все это необходимо для неопределенного списка неподконтрольных окружений, поэтому клиентский код собирается вебпаком со всеми выкрутасами.

As I said before, learning to use Webpack is beyond the scope of this tutorial. My task is to show that you can write universal web applications without any specific configs. And still I will pay attention to some key points.

The entry point is with us ./src/app.js

     entry: {
         app: [
	     path.resolve(__dirname, 'src/app'),
	 ]
    },

Bundles are generated in the folder ./dist and referred to the following rules:

    output: {
        path: path.resolve(__dirname, 'dist'),
	publicPath: '/',
	filename: `[name]-${ VERSION }.bundle.[hash].js`,
	chunkFilename:  `[name]-${ VERSION }.bundle.[chunkhash].js`,
    },

All code except 3rd-party modules is passed through Babel:

    {
        test: /\.(js)$/,
	exclude: /(node_modules|bower_components)/,
	loader: 'babel-loader',
    },

The most controversial piece of the config:

    new WrapperPlugin({
        test: /\app(.*).js$/,
	header: '(function(){"use strict";\nreturn\t',
	footer: '\n})()();'
    }),

Previously, I used WrapperPlugin purely in order to apply strict mode immediately for the entire app bundle. However, for universal web applications, I also use it to export the application as IIFE , i.e. run the application immediately as soon as the bundle has downloaded. Unfortunately, Webpack does not support IIFE as a libraryTarget . Perhaps this is the only piece of the config that I added for an isomorphic project. Although even he does not have a direct relationship with her, I could call the function manually.

Next, I put all the 3rd-party modules into a separate bundle:

    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',
        minChunks: ({ resource }) => (
            resource !== undefined &&
            resource.indexOf('node_modules') !== -1
        )
    }),

As promised, I’m adding the bundles to the end of my template for the template and generating index.html :

    new HtmlWebpackPlugin({
        template: './views/_index.html',
	filename: '../views/index.html',
	minify: {
	    html5: true
	},
	hash: true,
	cache: true,
	showErrors: false,
    }),

I clean the output directory and copy the static assets there:

    new CleanWebpackPlugin(['dist']),
    new CopyWebpackPlugin([{
	from: 'assets',
	force: true
     }]),

The remaining parts of the config are not of interest in the context of the project. There are all sorts of UglifyJSPlugin , BundleAnalyzerPlugin and other useful and not very things.



A little more server


Two files, announced in the first part of the tutorial, were left without implementation: “req middleware” and “err middleware” . The last file is the usual Error-handling middleware express. With it, we will give a special page (./views/error.html) with purely server errors, or json if a server error occurred during an ajax request. While it will look something like this:

module.exports = () => (err, req, res, next) => {
    res.status(500);
    (req.accepts(['html', 'json']) === 'json') ?
        res.json({ errors: { [err.name]: [err.message] } }) : 
        res.render('error', { err });
};

The slightly strange format of the json response is due to the fact that I immediately mimic the error format accepted in the RealWorld specification . For unification, so to speak.

We will leave the second “req middleware” idle for now, but I'm sure it will come in handy.

module.exports = () => (req, res, next) => next();



Testing SSR and hydrate


I’m sure that everything, so, is aware of how you can check the operation of SSR - just open the “ View page code ” and see that the #app tag is not empty (as is usually the case in SPA), but contains the markup of our application. Cool hydrate is a little trickier.

An attentive eye could notice this incomprehensible piece of code, which, as it were, “neither to the village nor to the city” is present in our server template index.html :

window.msgEl = document.getElementById('msg');

It is with his help that we can check whether our "hydration" works or not. Open the console and enter:

msgEl === document.getElementById('msg');

If true , then the item has not been redrawn by client code. You can also experiment and set the value Ractive.defaults.enhance = false; , rebuild and restart the application and make sure that in this case this check will return false . Which means the client code redraws the markup.

Thus, both SSR and “hydration” work perfectly with both static and dynamic, as well as very dynamic values ​​(calculated properties). Which was required to check.

Repository
Demo

In the next part of this tutorial, we will solve two more key problems of isomorphic web applications:isomorphic routing and navigation , and repeated fetching and initial data state . Despite the fact that I am going to devote a separate article to these issues, the solution itself will take us literally 5 lines of application code. Do not switch!

Thanks for attention! If you like it, stay tuned! All involved with the holiday and good luck!

UPD: Developing an isomorphic RealWorld application with SSR and Progressive Enhancement. Part 3 - Routing & Fetching

Only registered users can participate in the survey. Please come in.

About detailing articles