patx/youtube-clone

import { createServer } from 'node:http';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { routes } from './routes.js';
import { HttpError, sendJson } from './http.js';
import { parseCookies, userForToken, COOKIE } from './auth.js';
import { sendFile, safeJoin } from './static.js';
import { PUBLIC_DIR, MEDIA_DIR, VIDEO_DIR, THUMB_DIR } from './paths.js';

for (const dir of [VIDEO_DIR, THUMB_DIR]) mkdirSync(dir, { recursive: true });

const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '127.0.0.1';

/** Compile "GET /api/videos/:id" into a matcher. */
const table = Object.entries(routes).map(([key, handler]) => {
  const [method, pattern] = key.split(' ');
  const names = [];
  const source = pattern
    .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    .replace(/:(\w+)/g, (_, name) => { names.push(name); return '([^/]+)'; });
  return { method, regex: new RegExp(`^${source}$`), names, handler };
});

function match(method, pathname) {
  let pathExists = false;
  for (const route of table) {
    const m = route.regex.exec(pathname);
    if (!m) continue;
    pathExists = true;
    if (route.method !== method) continue;
    const params = {};
    route.names.forEach((name, i) => { params[name] = decodeURIComponent(m[i + 1]); });
    return { handler: route.handler, params };
  }
  return pathExists ? { methodMismatch: true } : null;
}

const server = createServer(async (req, res) => {
  const started = Date.now();
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  const pathname = url.pathname.replace(/\/+$/, '') || '/';

  res.on('finish', () => {
    if (process.env.QUIET) return;
    const ms = Date.now() - started;
    console.log(`${req.method} ${url.pathname} ${res.statusCode} ${ms}ms`);
  });

  try {
    // Media files (videos + thumbnails), served with Range support.
    if (pathname.startsWith('/media/')) {
      const file = safeJoin(MEDIA_DIR, pathname.slice('/media'.length));
      if (file && (await sendFile(req, res, file, { cacheControl: 'public, max-age=86400' }))) return;
      return sendJson(res, 404, { error: 'Media not found.' });
    }

    if (pathname.startsWith('/api/')) {
      const found = match(req.method, pathname);
      if (!found) return sendJson(res, 404, { error: `No API route for ${pathname}` });
      if (found.methodMismatch) return sendJson(res, 405, { error: `${req.method} is not allowed here.` });

      const token = parseCookies(req.headers.cookie || '')[COOKIE];
      const user = userForToken(token);
      const ctx = {
        req, res, url, token, user,
        params: found.params,
        query: url.searchParams,
        requireUser() {
          if (!user) throw new HttpError(401, 'You need to sign in to do that.');
          return user;
        },
      };
      await found.handler(ctx);
      if (!res.writableEnded) sendJson(res, 204, {});
      return;
    }

    // Static assets, then the SPA shell for every other path.
    const asset = safeJoin(PUBLIC_DIR, pathname);
    if (asset && pathname !== '/' && (await sendFile(req, res, asset, { cacheControl: 'no-cache' }))) return;
    if (await sendFile(req, res, join(PUBLIC_DIR, 'index.html'), { cacheControl: 'no-cache' })) return;
    sendJson(res, 404, { error: 'Not found.' });
  } catch (err) {
    if (res.writableEnded) return;
    const status = err instanceof HttpError ? err.status : 500;
    if (status >= 500) console.error(`error on ${req.method} ${pathname}:`, err);
    sendJson(res, status, { error: status >= 500 ? 'Something went wrong on our end.' : err.message });
  }
});

server.headersTimeout = 10 * 60 * 1000;
server.requestTimeout = 30 * 60 * 1000; // long enough for a big upload

server.listen(PORT, HOST, () => {
  console.log(`MeTube running at http://${HOST}:${PORT}`);
});