Создаём проект c OAuth и NoSQL за $0,00

Уже очень давно мне хотелось попробовать создать проект, который бы представлял собой настоящие JavaScript Application, а именно толстый клиент, без backend и своего хостинга, на основе open source и какого-нибудь BaaS/DaaS. К тому же я окончательно устал от jsperf.com, от этих бессмысленных двух шагов, от отсутствия хоть какого-то редактора кода и нормального поиска и от постоянной потери своих тестов, а история с капчой, которая не всегда срабатывает, окончательно добила меня. Я наконец выкроил время, чтобы осуществить давно задуманное и убить двух зайцев, реализовав альтернативу jsperf.

57eebc6645664e7091a560845ffee5ad.png

Итак, перво-наперво требования к проекту:

  • компактный и понятный интерфейс, без шагов и капчи;
  • нормальный редактор с подсветкой кода, а не просто textarea;
  • сохранение бенчмарка, чтобы потом его можно было без труда найти, а также удаление (всякое бывает);
  • возможность скачать бенчмарк и запустить его локально или через Node.js;
  • возможность добавить в «Избранное»;
  • другие ништяки.


А теперь самое интересное: где хранить исходники тестов? А результаты?

Где хранить исходники?


Если среди вас есть постоянные пользователи jsperf, то они помнят недавнюю историю, когда он был полностью недоступен именно по причине хранения кода и результатов прогона тестов. Так что задача сводилась к одному: как сделать так, чтобы ничего не хранить у себя, а переложить это на какой-нибудь сервис, а лучше на юзера? Ответ напрашивался сам собой: идеальное место для хранения исходников — GitHub, точнее Gist. Он есть практически у каждого разработчика, и это решает сразу несколько поставленных задач:

  • хранение;
  • нормальный поиск (по уникальному тегу);
  • избранное;
  • бонусом: история изменения тестов (diff) и fork«и.


У GitHub есть чудесный REST API, это, наверное, одна из эталонных реализаций, также есть API и для работы с Gist. Вопрос оставался за малым: как сохранить Gist от имени пользователя?

OAuth


Для авторизации GitHub предлагает использовать OAuth, это несложно, но требует минимального backend«а. Тут можно было пойти несколькими путями:

  1. Найти какой-нибудь бесплатный хостинг или BaaS и развернуть там одно из open source решений для работы с GitHub.
  2. Воспользоваться сервисом OAuth.io, у которого есть приемлемый free-план.


Я выбрал OAuth.io, как очень простой и быстрый способ для начала работы, к тому же при необходимости от сервиса можно безболезненно избавиться. Плюс у него есть неплохая аналитика, простенькая либа для работы API и куча провайдеров под любой сервис, в том числе и GitHub. А самый кайф, что для начала работы вам даже не нужно проходить нудную регистрацию, — просто нажимаете «Sign in with GitHub» и добавляете ключи от вашего приложения.

GitHub API


Следующий шаг — это написание обёртки для работы с GitHub API. И тут есть небольшой нюанс: я очень хотел лишний раз не дёргать OAuth.io, чтобы не выходить за лимиты free-плана. Как оказалось, GitHub позволяет обращаться к API неавторизованным, но такие вызовы жёстко лимитируются, поэтому метод получения Gist имеет достаточно нетривиальную логику:

  1. Проверяем Runtime cache, если есть данные, отдаём.
  2. Если в localStorage есть данные о юзере, считаем, что он уже авторизован, вызываем получение токена через OAuth.io и делаем запрос к API. Если авторизация не прошла, отправляем запрос неавторизованным и надеемся, что лимиты ещё не исчерпаны.
  3. Если в localStorage ничего нет, делаем запрос как неавторизованный, в случае ошибки пытаемся авторизоваться через OAuth.io и повторить запрос уже как авторизованный.


Переводим это в код, посыпаем Promise + fetch и получаем вот такой метод:

function findOne(id) {
        let promise;
        const url = 'gists/' + id;
        const _fetch = () => {
                return fetch(API_ENDPOINT + url).then(res => {
                        if (res.status !== API_STATUS_OK) {
                                throw 'Error: ' + res.status;
                        }

                        return res.json();
                });
        };

        if (_gists[id]) {
                // Runtime cache
                promise = Promise.resolve(_gists[id]);
        } else if (github.currentUser) {
                // Есть авторизация, запрашиваем Gist через OAuth.io
                promise = _call('get', url)['catch'](() => {
                        // Ошибка, пробуем запросить напрямую у GitHub API
                        github.setUser(null);
                        return _fetch();
                });
        } else {
                // Нет авторизации, обращаемся напрямую к GitHub API
                promise = _fetch()['catch'](() => {
                        // Ошибка, пробуем авторизоваться и запросить повторно
                        return _call('get', url);
                });
        }
        
        return promise.then(gist => {
                // Добавляем в Runtime cache
                _gists[gist.id] = gist;
                return gist;
        });
}

Где хранить результаты?


Если с исходниками я определился быстро, то вот с результатами не всё так просто. Не буду томить, просто перечислю решения, которые я знал на тот момент:

  • Parse.com — BaaS, есть опыт использования, удобный JS SDK;
  • MongoLab.com — DaaS, микроскопический опыт использования, требуется JS-велосипед для работы;
  • Firebase.com — DaaS+, опыта не было, есть JavaScript SDK и кое-что ещё ;).


Так как проект был экспериментальным, выбор пал на Firebase: кроме JavaScript API, он предлагал в два раза больше места, чем на том же MongoLab, — целый гигабайт.

На самом деле был ещё один вариант: localStorage/IndexedDB + WebRTC. Идея заключалась в следующем: результаты прогонов храним в localStorage и если в онлайне есть ещё кто-нибудь, то синхронизируем данные:).

Итак, Firebase. Использовать его до безобразия просто, документация не врёт: https://www.firebase.com/docs/web/quickstart.html.

// Создаём экземпляр Firebase (предварительно создав application, в моём случае это JSBench)
const firebase = new Firebase('https://jsbench.firebaseio.com/');

// Подписываемся на событие изменения «узла» (stats / {gist_id} / {revision_id}):
firebase.child('stats').child(gist.id).child(getGistLastRevisionId(gist)).on('value', (snapshot) => {
        const values = snapshot.val();
        // Обрабатываем данные
});

// Где-то в коде в какой-то момент добавляем данные
firebase.child('stats').child(gist.id).child(getGistLastRevisionId(gist)).push(data);


Это весь код, который мне пришлось написать для работы с Firebase, но самое классное, что событие обновления «узла» срабатывает всякий раз, когда кто-либо запускает бенчмарки, и вы прямо онлайн, без каких-либо F5 или cmd + r, получаете обновление графиков.

Дизайн


Эх, вот чего не умею, того не умею, поэтому всё выглядит так:
4704a76331b34ab998a1fa1c54bb5833.png

Интерфейс максимально информативен, отображены основные важные параметры, справа от кода теста выводится результат прогона, после завершения теста строчки подсвечиваются соответствующим цветом, а под кодом строятся графики. Setup и Teardown вынесены в «уши» внизу экрана — решение спорное, но подходит для большинства задач. В итоге вся возможная информация умещается на одном экране.

Как можно заметить, в отличие от jsperf, у меня есть подсветка кода, для этого используется Ace.

Ace — это чудесный инструмент для интеграции редактора кода в ваше приложение. Чтобы его использовать:

// Создаём инстанс
const editor = ace.edit(this.el);

// Устанавливаем тему
editor.setTheme('ace/theme/tomorrow');

// Включаем поддержку JavaScript
editor.getSession().setMode('ace/mode/javascript');

// Определяем максимальное и минимальное расширение редактора
editor.setOption('maxLines', 30);
editor.setOption('minLines', 4);

// Включаем автопрокрутку
editor.$blockScrolling = Number.POSITIVE_INFINITY;

// Подписываемся на изменения
editor.on('change', () => {
   const value = editor.getValue();
   // ...
});


Для прогона тестов используются Platform.js и Benchmark.js, графики рисую при помощи Google Visualization, так что результаты прогона и графики выглядят точно так же, как на jsperf.

Шаринг


Одна из фич — это шаринг теста, сейчас поддерживаются только Twitter и Facebook.

Twitter


Тут особо нечего рассказывать: открываем popup с предустановленным текстом, а дальше пользователь сам решает, постить или нет.

function twitter(desc, url, tags) {
   const max = 130;
   const top = Math.max(Math.round((SCREEN_HEIGHT / 3) - (twttr.height / 2)), 0);
   const left = Math.round((SCREEN_WIDTH / 2) - (twttr.width / 2));
   const message = desc.substr(0, max - (url.length + tags.length)) + ': ' + url + ' ' + tags;
   const params = 'left=' + left + ',top=' + top + ',width=' + twttr.width + ',height=' + twttr.height;
   const extras = ',personalbar=0,toolbar=0,scrollbars=1,resizable=1';

   window.open(twttr.url + encodeURIComponent(message), 'twitter', params + extras);
}

Facebook


Вот тут интереснее, хотелось не просто ссылку постить, а сразу график в ленту. У Google Visualization есть метод получения dataURI, а у FB — Graph API, осталось их подружить:

Обвязка над Facebook SDK
const facebook = {
    appId: 'XXXXXXX',
    publichUrl: 'https://graph.facebook.com/me/photos',

    init() {
                return this._promiseInit || (this._promiseInit = new Promise(resolve => {
                        window.fbAsyncInit = () => {
                                const FB = window.FB;

                                FB.init({
                                        appId: this.appId,
                                        version: 'v2.5',
                                        cookie: true,
                                        oauth: true
                                });

                                resolve(FB);
                        };
                        
                        // Стандартный код публикации
                        (function (d, s, id) {
                                var fjs = d.getElementsByTagName(s)[0], js;
                                if (d.getElementById(id)) {return;}
                                js = d.createElement(s);
                                js.id = id;
                                js.src = '//connect.facebook.net/en_US/sdk.js';
                                fjs.parentNode.insertBefore(js, fjs);
                        })(document, 'script', 'facebook-jssdk');
                }));
        },

        login() {
                return this._promiseLogin || (this._promiseLogin = this.init().then(api => {
                        return new Promise((resolve, reject) => {
                                api.login((response) => {
                                        if (response.authResponse) {
                                                resolve(response.authResponse.accessToken);
                                        } else {
                                                reject(new Error('Access denied'));
                                        }
                                }, {
                                        scope: 'publish_actions'
                                });
                        });
                }));
        }
};


Для преобразования dataURI используем https://github.com/blueimp/JavaScript-Canvas-to-Blob/.

И публикуем:

function facebookPublish(dataURI, message) {
    return facebook.login().then(token => {
                const file = dataURLtoBlob(dataURI);
                const formData = new FormData();

                formData.append('access_token', token);
                formData.append('source', file);
                formData.append('message', message);

                return fetch(facebook.publishUrl, {
                        method: 'post',
                        mode: 'cors',
                        body: formData
                });
        });
}

Планы на будущее

  • Поддержка ES6.
  • Подключение сторонних либ для теста.
  • Комментарии к бенчмарку (поддержка Markdown).
  • Просмотр ревизий и fork«ов.

Полный список используемых библиотек и полифилов

Итог


Как видите, в настоящий момент можно собирать качественный прототип с помощью готовых решений и бесплатных платформ, затрачивая на это только своё время. А ведь это не просто экономия денег — это и экономия времени на выбор хостинга, установку, настройку необходимого софта и дальнейшее администрирование. Все эти проблемы исчезают, когда вы используете любой BaaS или DaaS — они дают вам готовое решение без головной боли. Плюс, если проект вырастет и окрепнет, вы всегда можете перейти на подходящий платный тариф или поднять точно такой же стек на своём хостинге.

Страница проекта: http://jsbench.github.io/
Исходный код и задачи: https://github.com/jsbench/jsbench.github.io/

© Habrahabr.ru