Computer Keyboard - Głównie JavaScript

Głównie JavaScript

by Jakub T. Jankiewicz

Server WWW w przeglądarce

Duży napis WEB złożony z innych słów

Nie jest to implementacja serwera www całkowicie w JavaScript oraz przeglądarce, ale serwowanie statycznych plików, tworzonych w przeglądarce, tak jakby były zwracane przez prawdziwy serwer, więc można, z przymrużeniem oka, nazwać go serwerem www w przeglądarce.

Maiłem okazje napisać taki kod, przy pisaniu małej aplikacji GIT Web Terminal, która korzystając z biblioteki isomorphic-git, udostępnia interface wiersza poleceń do ograniczonego korzystania z gita. Można sklonować repozytorium z GitHuba edytować pliki używając komend vi lub emacs (vi czasami się wykrzacza). Można także commit-ować i push-ować zmiany z powrotem do GitHuba. Jedną z funkcji jest możliwość podglądu plików, które są zapisywane do bazy indexedDB za pomocą biblioteki BrowserFS. Można otwierać pliki za pomocą komendy view, która otwiera plik w iframe-ie, który wygląda jak przeglądarka www. Można także otwierać pliki poprzez adres url. Dodając między adresem strony, a ścieżką do pliku, ciąg znaków __browserfs__. Dzięki temu istnieje np. możliwość edycji aplikacji www kontrolowanej przez git-a, z poziomu przeglądarki i natychmiastowego podglądu w jednej zakładce przeglądarki.

Sposób funkcjionowania tego mechanizmu to Service Worker.

Jest to narzędzie, które zostało głównie stworzone w celu cache-owania danych w przeglądarce, aby była możliwość korzystania z aplikacji, kiedy nie ma internetu (offline). Głównymi aplikacjami, które korzystają z tego narzędzia są PWA, o których ostatnio było bardzo głośno.

Service Worker znajduje się między przeglądarką a serwerem www, czyli działa jak proxy. Można nasłuchiwać na zdarzenie fetch, które wywołuje się za każdym razem gdy strona wysyła zapytanie HTTP do serwera, i nie chodzi tylko o AJAXa i funkcje fetch, ale o każde zapytanie wysyłane przez przeglądarkę. Inną cechą Service Worker-ów, jest to że działają nawet gdy strona, która go utworzyła została zamknięta. Service Worker zostanie w takim przypadku uśpiony, dopóki nie nastąpi ponowne zapytanie HTTP, które znajdzie się w jego zakresie. Ważne jest także to, że Service Worker ma dostęp do bazy indexedDB czyli miejsca gdzie przechowuje pliki w mojej aplikacji.

Poniżej kod Service Worker-a, którego użyłem w aplikacji GIT Web Terminal.

self.importScripts('https://cdn.jsdelivr.net/npm/browserfs');

self.addEventListener('install', self.skipWaiting);

self.addEventListener('activate', self.skipWaiting);

self.addEventListener('fetch', function (event) {
    let path = BrowserFS.BFSRequire('path');
    let fs = new Promise(function(resolve, reject) {
        BrowserFS.configure({ fs: 'IndexedDB', options: {} }, function (err) {
            if (err) {
                reject(err);
            } else {
                resolve(BrowserFS.BFSRequire('fs'));
            }
        });
    });
    event.respondWith(fs.then(function(fs) {
        return new Promise(function(resolve, reject) {
            function sendFile(path) {
                fs.readFile(path, function(err, buffer) {
                    if (err) {
                        err.fn = 'readFile(' + path + ')';
                        return reject(err);
                    }
                    var ext = path.replace(/.*\./, '');
                    var mime = {
                        'html': 'text/html',
                        'json': 'application/json',
                        'js': 'application/javascript',
                        'css': 'text/css'
                    };
                    var headers = new Headers({
                        'Content-Type': mime[ext]
                    });
                    resolve(new Response(buffer, {headers}));
                });
            }
            var url = event.request.url;
            var m = url.match(/__browserfs__(.*)/);
            function redirect_dir() {
                return resolve(Response.redirect(url + '/', 301));
            }
            function serve() {
                fs.stat(path, function(err, stat) {
                    if (err) {
                        return resolve(textResponse(error404Page(path)));
                    }
                    if (stat.isFile()) {
                        sendFile(path);
                    } else if (stat.isDirectory()) {
                        if (path.substr(-1, 1) !== '/') {
                            return redirect_dir();
                        }
                        fs.readdir(path, function(err, list) {
                            if (err) {
                                err.fn = 'readdir(' + path + ')';
                                return reject(err);
                            }
                            var len = list.length;
                            if (list.includes('index.html')) {
                                sendFile(path + '/index.html');
                            } else {
                                listDirectory({fs, path, list}).then(function(list) {
                                    resolve(textResponse(fileListingPage(path, list)));
                                }).catch(reject);
                            }
                        });
                    }
                });
            }
            if (m) {
                var path = m[1];
                if (path === '') {
                    return redirect_dir();
                }
                console.log('serving ' + path + ' from browserfs');
                serve();
            } else {
                if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
                    return;
                }
                //request = credentials: 'include'
                fetch(event.request).then(resolve).catch(reject);
            }
        });
    }));
});
// -----------------------------------------------------------------------------
function listDirectory({fs, path, list}) {
    return new Promise(function(resolve, reject) {
        var items = [];
        (function loop() {
            var item = list.shift();
            if (!item) {
                return resolve(items);
            }
            fs.stat(path + '/' + item, function(err, stat) {
                if (err) {
                    err.fn = 'stat(' + path + '/' + item + ')';
                    return reject(err);
                }
                items.push(stat.isDirectory() ? item + '/' : item);
                loop();
            });
        })();
    });
}

// -----------------------------------------------------------------------------
function textResponse(string, filename) {
    var blob = new Blob([string], {
        type: 'text/html'
    });
    return new Response(blob);
}

// -----------------------------------------------------------------------------
function fileListingPage(path, list) {
    var output = [
        '<!DOCTYPE html>',
        '<html>',
        '<body>',
        `<h1>BrowserFS ${path}</h1>`,
        '<ul>'
    ];
    if (path.match(/^\/(.*\/)/)) {
        output.push('<li><a href="..">..</a></li>');
    }
    list.forEach(function(name) {
        output.push('<li><a href="' + name + '">' + name + '</a></li>');
    });
    output = output.concat(['</ul>', '</body>', '</html>']);
    return output.join('\n');
}

// -----------------------------------------------------------------------------
function error404Page(path) {
    var output = [
        '<!DOCTYPE html>',
        '<html>',
        '<body>',
        '<h1>404 File Not Found</h1>',
        `<p>File ${path} not found in browserfs`,
        '</body>',
        '</html>'
    ];
    return output.join('\n');
}

Service Worker oprócz plików, zwraca także listing plików dla katalogu oraz zwraca stronę 404, w przypadku nie znalezienia pliku, w przeglądarkowym systemie plików.

Aby uruchomić ten Service Worker wystarczy poniższy kod:

if ('serviceWorker' in navigator) {
    var scope = location.pathname.replace(/\/[^\/]+$/, '/');
    if (!scope.match(/__browserfs__/)) {
        navigator.serviceWorker.register('sw.js', {scope})
                 .then(function(reg) {
                     reg.addEventListener('updatefound', function() {
                         var installingWorker = reg.installing;
                         console.log('A new service worker is being installed:',
                                     installingWorker);
                     });
                     // registration worked
                     console.log('Registration succeeded. Scope is ' + reg.scope);
                 }).catch(function(error) {
                     // registration failed
                     console.log('Registration failed with ' + error);
                 });
    }
}

Kod zabezpiecza się przed przypadkiem gdy aplikacje (samą siebie) odpala się poprzez browserfs czyli gdy sklonujemy ją do katalogu /git i odpalamy z adresu https://jcubic.github.io/git/browserfs/git/.

Ważną rzeczą w przypadku Service Worker-a jest to, aby był umieszczony w katalogu główny aplikacji. Ponieważ ma on możliwość przechwytywania zapytań HTTP, tylko dla adresów, które znajdują się w katalogu, w którym został umieszczony plik Service Worker-a lub w jednym z podkatalogów.

Jedno z ograniczeń Service Worker-a jest to, że można go odpalić tylko z prawdziwego pliku, nie można odpalać go z pliku, który sam jest zwracany przez innego Service Worker-a. Dlatego poprzez GIT Web Terminal, nie będzie można uruchomić aplikacji, która sama korzysta z Service Worker-a.

Biblioteka Open Source

Mechanizm dodawania zapytań HTTP w samej przeglądarce został zamknięty w małej bibliotece po nazwą Wayne.

Aby dodać obsługę systemu plików jak w powyższym przypadku można użyć takiego kodu:

import { Wayne, FileSystem } from 'https://cdn.jsdelivr.net/npm/@jcubic/wayne';
import FS from "https://cdn.skypack.dev/@isomorphic-git/lightning-fs";
import mime from "https://cdn.skypack.dev/mime";
import path from "https://cdn.skypack.dev/path-browserify";

const { promises: fs } = new FS("__wayne__");

const app = new Wayne();

app.use(FileSystem({ path, fs, mime, prefix: '__fs__' }));

W tym przypadku zamiast BrowserFS, użyłem lżejszej biblioteki LightningFS. Powyższy kod używa modułów ES, jest to możliwe, gdy instalujemy Service Worker z opcją module. Możesz o tym przeczytać na tej stronie web.dev. Analogicznie można używać zwykłego mechanizmu oraz importScripts, ale należy pamiętać, że ścieżki do bibliotek powinny używać modułów typu UMD.

źródło strony (aby zobaczyć kod na GitHubie, musisz kliknąć przycisk raw)
Komentarze

Hasło, które podasz umożliwi ponowne zalogowanie się i np. usunięcie komentarza, jest dobrowolne. Email jest szyfrowany i używany do wysyłania powiadomień o odpowiedziach do komentarzy oraz do pobierania awatara dzięki usłudze Gravatar.com.