백엔드이지만 코드 핫 리로드가 하고 싶어!

RanolP·2019년 7월 4일
5

보라! React스러운 이 스크립트를!

동기

저는 yarn build && yarn start 에 질렸고, nodemon 에도 질렸습니다. 좋아 그렇다면 시간도 있겠다, 한 번 이런 거 만들어보자!

실행

일단 전 react-scripts 의 감각이 상당히 좋았으므로 react-scripts 를 뜯기 시작했습니다.

너 뭐야....

모듈화를 착실히 한 결과 깃헙에서 보기 힘들어졌잖아! 라고 하기엔 너무나 긴 코드였다.

시크한 콘솔 제거반

뭐 하여튼 이런 식으로 이것저것 뜯어가며 만들었습니다.

사용한 것들

  • address : 제 가여운 WSL2는 가상화가 들어가서 아이피가 참 거시기합니다. 제가 일일히 접근 가능한 아이피 대역을 찾기 귀찮았으므로 알아서 구해봐 하고 넣었습니다.

    이거, 이거, 이거 만드는 데 들어갔음

  • chalk : colorette가 재밌어보였는데 역시 잘 검증된 chalk로 결정. 직접 짤 수도 있지만 더럽잖아요?
  • chokidar : 제가 file watch 코드를 로우하게 작성하고 싶지는 않았습니다.
  • detect-port-alt : react-scripts 가 쓰길래 저도 같이 포트 사용 여부 감지로 썼습니다.
  • express : 전 express 충이에요 히히. 아마 좀 더 범용성을 갖추려면 제 스크립트에서 start 함수를 execSync('yarn start')로 바꾸면 되지 않을까요?

결과

yarn만 쓰고, express를 쓰는 게 확실한 상황이라서 이렇게 작업했습니다.

scripts/dev.js

require('dotenv').config();

const express = require('express');
const { prepareUrls, clearConsole, categoryToConsole } = require('./utilities');
const chalk = require('chalk');
const chokidar = require('chokidar');
const ts = require('typescript');
const { execSync } = require('child_process');
const detect = require('detect-port-alt');
const fs = require('fs');

const PORT = process.env.PORT || 4321;
const HOST = process.env.HOST || '0.0.0.0';
// TODO: env var HTTPS are no-op
const IS_HTTPS = process.env.HTTPS !== false;
const isInteractive = process.stdout.isTTY;

detect(PORT, HOST)
  .then(result => {
    if (PORT != result) {
      console.log(`Something is already running on port ${PORT}`);
      console.log(`Would you try to use port ${result} instead?`);
      process.exit(1);
    }
  })
  .catch(err => {
    console.log(err);
    process.exit(1);
  })
  .then(main);

function main() {
  let lastServer;

  const watcher = chokidar.watch('src/**/*', {
    ignoreInitial: true,
    persistent: true
  });

  watcher.emit();

  if (build().hasError) {
    console.log();
    console.log(
      `Press ${chalk.cyan('A')} key to pause/restart the Automatic Reloading.`
    );
    console.log(
      `Press ${chalk.cyan('Ctrl + C')} key to exit this program immediately.`
    );
  } else {
    lastServer = start();
  }

  let automaticReloading = true;

  watcher.on('all', async (name, path) => {
    if (!automaticReloading || !path || name === 'addDir') {
      return;
    }

    console.log();

    switch (name) {
      case 'change':
        console.log(`${chalk.yellow('!')} File Changed: ${path}`);
        break;
      case 'add':
        console.log(`${chalk.yellow('!')} File Added: ${path}`);
        break;
      case 'unlink':
        console.log(`${chalk.yellow('!')} File Removed: ${path}`);
        break;
      case 'unlinkDir':
        console.log(`${chalk.yellow('!')} Folder Removed: ${path}`);
        break;
    }

    const { hasError, updatedFiles } = build();

    if (hasError) {
      return;
    }

    for (const file of updatedFiles) {
      try {
        delete require.cache[require.resolve(file)];
      } catch (_e) {
        // Not found on require cache. Maybe the file was added ;)
      }
    }

    const startServer = () => {
      console.log(chalk.gray('Server closed, Starting development server...'));
      lastServer = start();
    };

    if (!lastServer) {
      startServer();
    } else {
      lastServer.close(startServer);
    }
  });

  const stdin = process.stdin;

  // without this, we would only get streams once enter is pressed
  stdin.setRawMode(true);

  // resume stdin in the parent process (node app won't quit all by itself
  // unless an error or process.exit() happens)
  stdin.resume();

  // i don't want binary, do you?
  stdin.setEncoding('utf8');

  // on any data into stdin
  stdin.on('data', key => {
    // ctrl-c ( end of text )
    if (key === '\u0003') {
      process.exit();
    }
    if (key === 'A' || key === 'a') {
      if (automaticReloading) {
        console.log(
          chalk.yellow('Automatic Reloading paused. Feel free to edit!')
        );
        automaticReloading = false;
      } else {
        console.log(
          chalk.green(
            'Automatic Reloading started. Edit any file to reload it.'
          )
        );
        automaticReloading = true;
      }
    }
  });
}

const typescriptFile = /.tsx?$/;
const files = fs
  .readdirSync(`${process.cwd()}/src`)
  .filter(it => typescriptFile.test(it))
  .map(it => `src/${it}`);

function build() {
  console.log(`${chalk.cyan('i')} Waiting for TypeScript compiling...`);

  if (!fs.existsSync(`${process.cwd()}/tsconfig.json`)) {
    console.error(`${chalk.red('!')} No 'tsconfig.json' file.`);
    return { hasError: true, updatedFiles: [] };
  }
  const config = require(`${process.cwd()}/tsconfig.json`);

  const { options, errors } = ts.convertCompilerOptionsFromJson(
    config.compilerOptions,
    process.cwd()
  );

  const program = ts.createProgram(files, {
    listEmittedFiles: true,
    ...options
  });

  const emitResult = program.emit();

  const diagnostics = errors
    .concat(ts.getPreEmitDiagnostics(program))
    .concat(emitResult.diagnostics);
  const errorCount = diagnostics.filter(
    it => it.category === ts.DiagnosticCategory.Error
  ).length;

  if (errorCount && isInteractive) {
    clearConsole();
    console.log(`${chalk.red('!')} Compilation failed!`);
    console.log();
  }

  for (const diagnostic of diagnostics) {
    const { color, category } = categoryToConsole(diagnostic.category);
    if (!diagnostic.file) {
      console.log(
        `${color(category)} ${chalk.gray(`TS${diagnostic.code}:`)} ${
          diagnostic.messageText
        }`
      );
      continue;
    }
    const { line, character } = ts.getLineAndCharacterOfPosition(
      diagnostic.file,
      diagnostic.start
    );
    const filename = `${chalk.cyan(diagnostic.file.fileName)}:${chalk.yellow(
      line + 1
    )}:${chalk.yellow(character + 1)}`;
    console.log(
      `${filename} - ${color(category)} ${chalk.gray(
        `TS${diagnostic.code}:`
      )} ${diagnostic.messageText}`
    );
    console.log();

    const lineStart = ts.getPositionOfLineAndCharacter(
      diagnostic.file,
      line,
      0
    );

    console.log(
      `${chalk.bgWhite(
        chalk.black(` ${line + 1} `)
      )} ${diagnostic.file.text.substring(
        lineStart,
        diagnostic.start + diagnostic.length
      )}`
    );
    console.log(
      `${chalk.bgWhite(
        (line + 1).toString().replace(/./g, ' ') + '  '
      )} ${Array(diagnostic.start - lineStart)
        .fill(' ')
        .join('')}${color(
        Array(diagnostic.length)
          .fill('~')
          .join('')
      )}`
    );
  }

  if (errorCount) {
    console.log();
    console.log(`Found ${errorCount} error${errorCount != 1 ? 's' : ''}.`);
  }

  return {
    hasError: errorCount > 0,
    updatedFiles: emitResult.emittedFiles || []
  };
}

function start() {
  const app = express();

  const { initializeWebServer } = require('../dist/app');
  initializeWebServer(app);

  return app.listen(PORT, () => {
    if (isInteractive) {
      clearConsole();
      console.log(chalk.green('Compiled Successfully!'));
    }

    console.log();

    console.log(
      `${chalk.bold('app-name')} listening on ${chalk.cyan('' + PORT)}!`
    );

    console.log();

    const { lanUrl, localUrl } = prepareUrls({
      isHttps: false,
      port: PORT
    });
    console.log(`  ${chalk.bold('Local')}           : ${localUrl}`);
    if (lanUrl) {
      console.log(`  ${chalk.bold('On Your Network')} : ${lanUrl}`);
    }

    console.log();

    console.log('Note that the development server is not optimized.');
    console.log(
      `To create a production build, use ${chalk.cyan('yarn build')}.`
    );
    console.log(
      `Press ${chalk.cyan('A')} key to pause/restart the Automatic Reloading.`
    );
    console.log(
      `Press ${chalk.cyan('Ctrl + C')} key to exit this program immediately.`
    );
  });
}

scripts/utilties.js

const address = require('address');
const chalk = require('chalk');
const ts = require('typescript');

function prepareUrls({ isHttps, port }) {
  const protocol = isHttps ? 'https' : 'http';
  const formatUrl = hostname =>
    `${protocol}://${hostname}${port ? `:${port}` : ''}/`;

  let lanUrl;
  try {
    // This can only return an IPv4 address
    let lanUrlForConfig = address.ip();
    if (lanUrlForConfig) {
      // Check if the address is a private ip
      // https://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces
      if (
        /^10[.]|^172[.](1[6-9]|2[0-9]|3[0-1])[.]|^192[.]168[.]/.test(
          lanUrlForConfig
        )
      ) {
        // Address is private, format it for later use
        lanUrl = formatUrl(lanUrlForConfig);
      }
    }
  } catch (_e) {
    // ignored
  }

  return {
    lanUrl,
    localUrl: formatUrl('localhost')
  };
}

function clearConsole() {
  process.stdout.write(
    process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'
  );
}

function categoryToConsole(category) {
  switch (category) {
    case ts.DiagnosticCategory.Error:
      return {
        color: chalk.red,
        category: 'error'
      };
    case ts.DiagnosticCategory.Message:
      return {
        color: chalk.blue,
        category: 'message'
      };
    case ts.DiagnosticCategory.Suggestion:
      return {
        color: chalk.green,
        category: 'suggest'
      };
    case ts.DiagnosticCategory.Warning:
      return {
        color: chalk.yellow,
        category: 'warning'
      };
    default:
      return {
        color: s => s,
        category: 'unknown'
      };
  }
}

module.exports = { prepareUrls, clearConsole, categoryToConsole };

결론

이런 거 하지 마요. 이걸로 하루가 날아갔음.

+2019-07-06: 이것저것 메챠쿠챠 더 수정함

profile
사람과 컴퓨터 사이를 이어주는 소프트웨어를 만듭니다

2개의 댓글

comment-user-thumbnail
2019년 7월 4일

백엔드는 잘 모르지만 잘 보고갑니다!! 고생하셨어요~

답글 달기
comment-user-thumbnail
2019년 7월 5일

크 대단하십니다...

답글 달기