Локальный сервер для разработки статического сайта с нуля

Вступление

Локальный сервер разработки — полезный инструмент для web разработки и позволяет:

🤔 Зачем делать с нуля?

Всё это заложено в базовых инструментах, но интересно ведь сделать самим, чтобы чему-то научиться и разобраться: как те или иные вещи работают.

🔗 Предыдущий материал:

Если вы пропустили предыдущую вводную статью, то вот она - про то, как создаю генератор статического сайта с нуля на NodeJS!

Ключевые функции системы и идея процесса:

Как следить за изменениями в файлах?

Начал со стандартного fs.watch функционала в NodeJS. После проблем с дублированием событий от файловой системы я решил переключиться на chokidar, в качестве базового решения для кросс-платформенного окружения.

🤓 Интересно? Возможно вас интересует система, которая строит индекс и следит за реальными изменениями содержания файлов? Чтобы генератор запускался только тогда, когда содержание файла реально изменилось? Если да, обязательно напишите мне об этом!

Перегенерация сайта

Просто запускаем процесс перегенерации сайта в отдельном процессе. Необходимо сгенерировать особенный билд для разработки (добавление скрипта на каждую страницу, для перезагрузки страницы из дев-сервера).

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

export const debounce = (fn, ms) => {
  let timeoutId = null;
  return (...args) => {
    if (timeoutId != null) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
    return new Promise((resolve) => {
      timeoutId = setTimeout(() => {
        timeoutId = null;
        const result = fn(...args);
        resolve(result);
      }, ms);
    });
  };
};

Если предыдущий процесс генерации ещё не завершился - просто прерываем его (abort) и запускаем новый. Для примера, как может выглядеть использование такой утилиточной функции:

const proc = runProcess("node", ["--import", "tsx", "scripts/dev-rebuild.ts"]);
// после этого мы можем:
// 1. дождаться завершения процесса
await proc.promise;
// 2. или прервать и завершить процесс, при этом proc.promise так же должен завершиться (с ошибкой)
await proc.abort();

Локальный HTTP сервер

Тут мы просто создаём и запускаем стандартный самописный HTTP сервер (например, на http://localhost:3000/), который раздаёт файлы из локальной директории по соответствующим адресам.

Раздача файлов по адресам (роутинг)

Логика локального сервера должна, по возможности, повторяет основные настройки правил маршрутизации адресов вашего хостинга. Пример: правило обязательного добавления / в конец пути, или адрес соответствует определенной или корневой странице:

Режим разработки и перезагрузка страниц

Для режима разработки я добавляю Event Stream подключение, чтобы страница могла получать сообщение о том, что доступна новая версия сайта (после внесения изменений в код). Для подключение к EventStream со стороны локальной версии сайта, на каждую сгенерированную HTML страницу добавляем скрипт. Просто оборачиваем в <script> в конце <body> блока:

// инициируем канал сообщений с дев-сервером по адресу http://localhost:3000/dev-server
new EventSource("/dev-server").addEventListener("message", (ev) => {
  // если получаем от сервера сообщение `reload`
  if (ev?.data === "reload") {
    // перезагружаем страницу
    location.reload();
  }
});

Даже лучше, мы на лету по запросу добавлять этот сниппет самим локальным сервером при обработке всех валидных .html документов. Таким образом, генератор сайта вообще ничего не знает о существовании перезагрузок страницы, и все статические страницы (например, игровые кастомные страницы), так же получают возможность перезагрузки после перегенерации сайта.

На локальном сервере добавляем слушателя на event-stream подключение. Необходимо хранить все подключения в памяти и оповещать все открытые страницы сообщением о перезагрузке.

// здесь храним все локальные подключения
const clients = new Set();

http.createServer(async (req, res) => {
    // обрабатываем запрос по нужному адресу
    if (req.url === '/dev-server') {
        // указываем что данный запрос инициирует event-stream
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive'
        });
        // отправляем для теста пустое сообщение об инициализации, двойной перенос строки указывает на конец сообщения
        res.write('data: init\n\n');

        // добавляем новое соединение (еще одна страница открылась)
        clients.add(res);

        // завершаем обработчик и удаляем соединение
        req.on('close', () => {
            clients.delete(res);
            res.end();
        });
        return;
    }
    // ...
}

export const reloadPages = () => {
    for (const client of clients) {
        client.write('data: reload\n\n');
    }
};

Заключение

Моя система теперь:


❤️ Ваше мнение важно!