Статические файлы

Статические файлы — это CSS, JavaScript, изображения, шрифты и другие ресурсы, которые сервер отдаёт клиенту без обработки, напрямую из файловой системы или CDN.

Зачем нужно

Node.js-сервер может раздавать статику, но для высоконагруженных сайтов это неэффективно: сервер занят I/O вместо бизнес-логики. Правильная стратегия — отдавать статику через nginx reverse proxy или CDN, кешировать через заголовки, а Node.js использовать только для API. Понимание этого разделения критично для production-архитектуры.

Где используется

  • SPA (React, Vue, Angular) — index.html + чанки
  • API-сервер — favicon, robots.txt, OpenAPI docs
  • Медиафайлы загруженные пользователями (через Multer)
  • Email-assets (изображения в письмах)

Основной контент

Express.static — раздача из Node.js

const express = require('express');
const path = require('path');
const app = express;

// Раздавать из папки public/
app.use(express.static(path.join(__dirname, 'public')));

// С префиксом: /static/image.png → public/image.png
app.use('/static', express.static(path.join(__dirname, 'public'), {
  maxAge: '30d',          // Cache-Control: max-age=2592000
  immutable: true,        // файлы не изменятся (для хешированных имён)
  etag: true,
  lastModified: true,
  index: false,           // не искать index.html в директории
  dotfiles: 'deny'        // запрет .env, .htaccess
}));

Nginx — раздача статики (production)

# nginx.conf
server {
    listen 80;
    server_name example.com;

    # Статика — nginx обрабатывает сам
    location /static/ {
        alias /var/www/app/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        add_header Vary "Accept-Encoding";
        gzip_static on;  # отдавать .gz если есть
    }

    # Загруженные файлы
    location /uploads/ {
        alias /var/www/app/uploads/;
        expires 7d;
    }

    # API — проксировать в Node.js
    location /api/ {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Стратегия кеширования

Файлы с хешем в имени (Webpack, Vite):
  main.abc123.js, style.def456.css
  → Cache-Control: max-age=31536000, immutable
  → Кешируются на год

Файлы без хеша:
  index.html
  → Cache-Control: no-cache
  → Браузер проверяет ETag каждый раз

API-ответы:
  → Cache-Control: no-store
  → Не кешировать

SPA — отдача index.html для всех путей

// После API-маршрутов и статики
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

Безопасность при раздаче загруженных файлов

// Никогда не раздавать папку uploads напрямую
// app.use('/uploads', express.static('uploads')); // опасно!

// Безопасный вариант через маршрут с проверкой прав
app.get('/files/:filename', authMiddleware, async (req, res) => {
  const filename = path.basename(req.params.filename); // убрать ../
  const filePath = path.join(__dirname, 'uploads', filename);

  // Проверить что файл принадлежит пользователю
  const file = await FileService.getByFilename(filename);
  if (!file || file.userId !== req.user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  res.sendFile(filePath);
});

Частые ошибки

  • Раздавать статику через Node.js в production — nginx/CDN справляются на порядки эффективнее
  • Не устанавливать Cache-Control — браузер может не кешировать или кешировать слишком агрессивно
  • Раздавать uploads без аутентификации — приватные файлы доступны всем по прямой ссылке
  • Path Traversal уязвимостьreq.params.filename = '../../config.js'; всегда применять path.basename

Связанные темы

Ресурсы


🎓 Источник: Serving Static in Node.js

  • 📅 2019-09-23 · YouTube · [Marp](../../Documents/TimurShemsedinov/2019-09-23 — Serving Static in Node.js (n_AdKIzbpBc).md)
  • Тезисы:
    • Статика — не задача для ноды: nginx/CDN дешевле и быстрее, но иногда уместно (SPA, малый трафик, на старте)
    • Файловый указатель + индекс каталога: один Map по пути
    • try/catch НЕ ловит ошибку стрима — нужен stream.on('error')
    • Защита от path traversal: абсолютный путь + startsWith(rootDir) обязательно
    • Справочник MIME types по расширению (.jsapplication/javascript)
    • URL со слэшом в конце → отдаём index.html
    • ENOENT на createReadStream обрушивает процесс если не обработать
    • Кеш в Map в памяти — для маленьких файлов; быстрее диска в 10–100 раз
    • Большие файлы (>1MB) — через стрим, не в memory
    • res.end(buffer) блокирует на отправку, stream.pipe(res) — нет
  • Цитата: «Статика — не задача для ноды, но если уж раздаём — кэш в памяти даёт x10 к скорости»