(번역) Node.js 라이브러리를 Deno로 컨버팅한 방법

Taegeun Moon·2022년 6월 12일
2
post-thumbnail

이 글은 How we converted our Node.js library to Deno (using Deno)를 번역한 글 입니다. 의역이 다수 포함되어 있습니다.

세줄 요약

  1. EdgeDB는 공식 library를 NPM을 통해 제공하고 있었다.
  2. Deno에선 NPM을 사용하지 못하기 때문에, Deno지원을 위한 수정이 필요했다.
  3. Node.js 라이브러리를 자동으로 Deno 라이브러리로 변환해주는 툴을 최대한 심플하게 만들어 보았다.

Deno는 컴파일 없이 자체적으로 TypeScript를 지원하는 JavaSciprt 런타임 입니다. Deno는 Node.js의 창시자인 Ryan Dahl이 Node.js의 근본적인 설계 및 보안 문제를 해결하고, ES 모듈이나 TypeScript 등의 현대적인 best practice를 반영하기 위해 제작하였습니다.

EdgeDB는 NPM에서 edgedb라는 이름으로 사용 가능한 Node.js용 first-party 클라이언트 라이브러리를 제작 및 관리하고 있습니다. 그런데 Deno는 의존성 관리에 완전히 다른 의존성 관리법을 사용하고 있는데요, NPM이 아니라 deno.land/x 와 같은 퍼블릭 패키지 저장소의 URL로부터 직접 imports 하는 방법 입니다. 우리는 기존 코드베이스를 "Deno화" 하는 간단한 방법을 찾아보기로 결심했습니다. 즉, 기존 Node.js 구현에서 최소한의 리팩토링만을 통해 Deno 호환이 가능한 모듈을 만드는 것 입니다. 이는 거의 동일한 두개의 코드베이스를 동기화 하고 유지보수 해야하는 귀찮음을 없애줄 것입니다.

우리는 Deno를 지원하고자 하는 다른 라이브러리 제작자들에게 도움이 될법한 일반적인 접근법이라고 생각되는 "runtime adapter"라는 패턴에 도달했습니다.

Node.js vs Deno

Node.js로 작성된 라이브러리를 컨버팅 할 때 고려해야할 Node.js와 Deno 런타임의 핵심 차이점이 몇가지 있습니다.

  1. TypeScript 지원 : Deno는 직접적으로 TypeScript파일을 실행할 수 있는 반면, Node.js는 JavaScript 코드만을 실행할 수 있습니다.
  2. Module 탐색 : Node.js는 기본적으로 require/module.exports 문법을 사용하는 CommonJS 포맷으로 모듈을 임포트 합니다. 또한 node_modules 폴더에서 "react"와 같은 단순(plain) 이름을 통한 로딩 방법, 확장자 없는 import에 .js.json등 자동으로 필요한 확장자를 붙이는 방법, 그리고 경로를 통한 import에서 index.js 파일을 불러오는 등 복잡한 resolution algorithm을 사용합니다.
    Deno는 "ES modules" 또는 줄여서 "ESM"이라고 불리는 importexport 모듈 문법을 사용해 이를 획기적으로 간단하게 만들었습니다.
    이는 node_modules 폴더나 npmyarn같은 패키지 매니저가 더이상 필요 없음을 의미합니다. 즉, 외부 모듈은 URL이나 deno.land/x나 GitHub와 같은 공개 코드 저장소에서 직접적으로 import 됩니다.
  3. Standard Library : Node.js는 fs, path, crypto, http 등 기본 탑재된 여러 standard modules를 가지고 있습니다. 이 모듈들은 require('fs') 형식으로 직접적으로 불러와 지고, Node.js에 의해 이름이 선점되어 있습니다. 반면, Deno의 standard library는 https://deno.land/std/ 저장소 URL을 통해 import 됩니다. 두 standard library들 사이에는 기능적인 차이점도 존재합니다. Deno는 몇몇 오래된 구식 Node.js API들을 없애고, 새로운 standard library(Go 버전에서 착안함)를 도입하고, Promise와 같은 현대 JavaScript 기능을 전반적으로 지원합니다 (반면 많은 Node.js API들은 아직도 callback style을 사용중).
  4. 내장 전역변수 (Built-in globals) : Deno는 모든 core API들을 Deno라는 이름의 단일 전역 변수에 저장하고 있으며, 나머지는 모두 표준 web API들입니다. Node.js와는 다르게, Bufferprocess와 같은 전역 변수 사용이 불가능 합니다.

그렇다면 어떻게 이러한 차이점을 해결하면서도 최대한 단순하게 Node.js 라이브러리가 Deno에서 돌아가게 할 수 있을까요? 어떻게 바꾸어야 하는지 하나씩 알아보겠습니다.

TypeScript와 모듈 문법

다행히도, CommonJS require/module.exports 문법을 ESM import/export 스타일로 바꾸어야 하는 부분은 크게 걱정할게 없습니다. edgedb-js는 온전히 TypeScript로만 작성되었는데, 이미 ESM 문법을 사용하고 있습니다. tsc가 생성한 순정 JavaScript 파일들을 Node.js가 사용할 수 있도록 컴파일 과정에서 CommonJS require 문법으로 변환되는 형태입니다.

이 글의 나머지 부분에서는 TypeScript 소스코드를 어떻게 Deno에서 바로 사용될 수 있는 형태로 바꾸었는지에 대해서 이야기해 보겠습니다.

Dependencies

다행히도, edgedb-js는 third-party dependencies를 가지고 있지 않아서 외부 라이브러리의 Deno 호환 여부에 대해 걱정할 필요가 없었습니다. 하지만, Node.js의 standard library(e.g. path, fs 등)에 대한 import는 모두 Deno 버전으로 교체해야 했습니다.

NOTE: 여러분 코드가 외부 패키지를 사용하고 있다면, deno.land/x 에서 Deno 버전이 있는지 확인해 보세요. 있다면 사용법을 확인해 보시고, 없다면 모듈 제작자에게 Deno 버전을 만들어 달라고 부탁해야 합니다.

이 작업은 Deno standard library에서 제공해주는 Node.js 호환 모듈을 통해 쉽게 진행할 수 있습니다. 이 모듈은 Node의 API을 최대한 동일하게 구현하는 wrapper형태의 Deno standard library를 제공해 주고 있습니다.

// Node.js API
import * as crypto from "crypto";
// Deno API (wrapper)
import * as crypto from "https://deno.land/std@0.114.0/node/crypto.ts";

코드를 심플하게 만들기 위해, Node.js API에 대한 모든 import를 adapter.node.ts 라는 하나의 파일로 옮기고 필요한 기능만 re-export 하겠습니다.

// adapter.node.ts
import * as path from "path";
import * as util from "util";
import * as crypto from "crypto";

export {path, net, crypto};

그리고 Deno용으로 똑같은 adapter를 adapter.deno.ts라는 이름으로 구현하였습니다.

// adapter.deno.ts
import * as crypto from "https://deno.land/std@0.114.0/node/crypto.ts";
import path from "https://deno.land/std@0.114.0/node/path.ts";
import util from "https://deno.land/std@0.114.0/node/util.ts";

export {path, util, crypto};

Node.js의 특정 기능이 필요할때면, adapter.node.ts 에서 해당 기능을 직접 import를 하였습니다. 이렇게 하면, 단순히 adapter.node.ts를 import하는 모든 부분을 adpater.deno.ts로 교체함으로써 Deno에서 사용 가능한 edgedb-js를 만들 수 있었습니다. 위 두개의 파일이 동일한 기능을 re-export 하고 있는 한, 모든 코드들은 예상대로 동작할 것입니다.

그런데 현실적으로, 어떻게 이 import들을 모두 재작성 할 수 있을까요? 간단한 codemod* 스크립트를 작성할 필요성이 있어 보입니다. 그리고 그냥 좀 더 우아하게 보이기 위해, 이 스크립트도 Deno를 사용해서 작성해 보겠습니다.

역자: codemode script = code + modification script.
즉, 코드를 수정하는 스크립트를 뜻함.

Deno-ifier 만들기

제작에 들어가기 앞서, 우리가 만들 툴이 어떤 일들을 해야 하는지 그림을 그려보겠습니다.

  • Node.js 스타일 import를 Deno 스타일로 변경. 모든 path import에 대해 .ts 확장자와 /index.ts를 붙이는 것 포함.
  • adapter.node에 대한 모든 import를 adapter.deno.ts로 교체.
  • process, Buffer와 같은 Node.js 전역변수를 Deno화된 코드에 삽입. Adapter 에서 이 변수들을 export 할 수도 있지만, Node.js 파일들이 명시적으로 import하게 리팩토링 하기로 함. 간결성을 위해, Node.js 전역값이 사용된 부분을 발견할 때마다 해당 파일에 import문을 삽입.
  • 직접적으로 import하면 안된다는것을 표시하기 위해 edgedb-js 내부 코드를 포함하고 있는 src 폴더 명을 _src로 변경.
  • src/index.node.ts 파일을 프로젝트 루트 폴더로 옮기고 이름을 mod.ts로 변경. 이런 형태가 Deno에서 자연스러운 구조. (참고: 여기서 index.node.ts는 파일이 Node용임을 뜻하지 않음. 브라우저에서 실행될 코드인 edgedb-js를 export하는 index.browser.ts와 구분하기 위한것.)

전체 파일 목록 만들기

이제 시작해 보겠습니다. 먼저 우리가 가지고 있는 소스 파일 목록을 추려내야 합니다. Deno의 내장 fs 모듈이 제공하는 walk 함수로 다음과 같은 작업을 합니다:

import {walk} from "https://deno.land/std@0.114.0/fs/mod.ts";

const sourceDir = "./src";
for await (const entry of walk(sourceDir, {includeDirs: false})) {
  // iterate through all files
}

참고: Node 호환 버전인 std/node/fs가 아니라 Deno 네이티브 모듈인 std/fs를 사용.

몇가지 재작성 규칙들을 선언하고, 각 소스파일을 재작성된 결과물이 저장될 경로에 매핑할 Map을 초기화 합니다.

const sourceDir = "./src";
const destDir = "./edgedb-deno";
const pathRewriteRules = [
  {match: /^src\/index.node.ts$/, replace: "mod.ts"},
  {match: /^src\//, replace: "_src/"},
];

const sourceFilePathMap = new Map<string, string>();

for await (const entry of walk(sourceDir, {includeDirs: false})) {
  const sourcePath = entry.path;
  sourceFilePathMap.set(sourcePath, resolveDestPath(sourcePath));
}

function resolveDestPath(sourcePath: string) {
  let destPath = sourcePath;
  // apply all rewrite rules
  for (const rule of pathRewriteRules) {
    destPath = destPath.replace(rule.match, rule.replace);
  }
  return join(destDir, destPath);
}

지금까지는 꽤 쉬운 내용이었습니다. 이제 소스코드 내용을 수정해 봅시다.

imports와 exports 재작성하기

import 경로를 재작성하기 위해, 파일 어디에 import 문이 있는지를 알아야 합니다. 다행히도 TypeScript는 Compiler API를 외부에 노출하고 있는데요, 이를 활용해 소스파일을 추상 문법 트리 (Abstract Syntax Tree, AST)로 파싱하여 import 선언문을 찾아보겠습니다.

이를 위해선 "typescript" NPM 모듈에서 직접 컴파일러 API를 import 해야 합니다. 다행히도, Deno의 호환성 모듈(compatibility module)이 큰 번거로움 없이 CommonJS 모듈을 require 할 수 있도록 해줍니다. 이 기능을 쓰려면 Deno를 실행할 때 --unstable 플래그를 사용해야 하는데, 지금과 같은 빌드 단계에서의 사용은 문제될게 없습니다.

import {createRequire} from "https://deno.land/std@0.114.0/node/module.ts";

const require = createRequire(import.meta.url);
const ts = require("typescript");

이제 소스 파일들을 순회하며 하나씩 차례대로 파싱해 봅시다.

import {walk, ensureDir} from "https://deno.land/std@0.114.0/fs/mod.ts";
import {createRequire} from "https://deno.land/std@0.114.0/node/module.ts";

const require = createRequire(import.meta.url);
const ts = require("typescript");

for (const [sourcePath, destPath] of sourceFilePathMap) {
  compileFileForDeno(sourcePath, destPath);
}

async function compileFileForDeno(sourcePath: string, destPath: string) {
  const file = await Deno.readTextFile(sourcePath);
  await ensureDir(dirname(destPath));

  // if file ends with '.deno.ts', copy the file unchanged
  if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file);
  // if file ends with '.node.ts', skip file
  if (destPath.endsWith(".node.ts")) return;

  // parse the source file using the typescript Compiler API
  const parsedSource = ts.createSourceFile(
    basename(sourcePath),
    file,
    ts.ScriptTarget.Latest,
    false,
    ts.ScriptKind.TS
  );
}

파싱된 각각의 AST에서, 최상단 노드부터 순회하며 import문과 export문을 찾아봅시다. import/export 문은 항상 최상단에 위치하기 때문에 너무 아래쪽까지 볼 필요는 없습니다. (dynamic import()는 아닐수도 않지만, edgedb-js에선 사용하지 않고 있음).

해당 노드들로부터, 소스 파일에서 import/export 문의 시작과 끝 위치(offset)를 알아냅니다. 그다음 현재 내용물을 잘라내고, 변경된 path를 삽입하여 import를 재작성하면 됩니다.

const parsedSource = ts.createSourceFile(/*...*/);

const rewrittenFile: string[] = [];
let cursor = 0;
parsedSource.forEachChild((node: any) => {
  if (
    (node.kind === ts.SyntaxKind.ImportDeclaration ||
      node.kind === ts.SyntaxKind.ExportDeclaration) &&
    node.moduleSpecifier
  ) {
    const pos = node.moduleSpecifier.pos + 2;
    const end = node.moduleSpecifier.end - 1;
    const importPath = file.slice(pos, end);

    rewrittenFile.push(file.slice(cursor, pos));
    cursor = end;

    // replace the adapter import with Deno version
    let resolvedImportPath = resolveImportPath(importPath, sourcePath);
    if (resolvedImportPath.endsWith("/adapter.node.ts")) {
      resolvedImportPath = resolvedImportPath.replace(
        "/adapter.node.ts",
        "/adapter.deno.ts"
      );
    }

    rewrittenFile.push(resolvedImportPath);
  }
});

rewrittenFile.push(file.slice(cursor));

여기서 핵심은 resolveImportPath 함수인데, 이 함수는 몇가지 시도 해 보며 Node 스타일 local import를 Deno 스타일로 변환해 줍니다. 첫번째로 path가 실제 disk상의 파일에 해당하는지 체크하고, 아닌 경우 .ts를 붙여 다시 시도하고, 실패하는 경우 /index.ts 붙여 다시 시도하고, 실패하는 경우 에러를 throw 합니다.

Node.js 전역변수 주입

마지막 단계는 Node.js 전역 변수를 처리입니다. 우선, 프로젝트 폴더에 globals.deno.ts 파일을 생성합니다. 이 파일은 우리 패키지에서 사용된 Node.js 전역 변수들의 Deno 호환성 버전을 export 해야 합니다.

export {Buffer} from "https://deno.land/std@0.114.0/node/buffer.ts";
export {process} from "https://deno.land/std@0.114.0/node/process.ts";

컴파일된 AST는 소스파일에서 사용된 모든 식별자(역: 변수명, 함수명 등)들을 Set으로 제공해 줍니다. 이를 사용해 해당 전역 변수들을 참조하는 모든 파일들에 import 문을 삽입해 주겠습니다.

const sourceDir = "./src";
const destDir = "./edgedb-deno";
const pathRewriteRules = [
  {match: /^src\/index.node.ts$/, replace: "mod.ts"},
  {match: /^src\//, replace: "_src/"},
];
const injectImports = {
  imports: ["Buffer", "process"],
  from: "src/globals.deno.ts",
};

// ...

const rewrittenFile: string[] = [];
let cursor = 0;
let isFirstNode = true;
parsedSource.forEachChild((node: any) => {
  if (isFirstNode) {  // only run once per file
    isFirstNode = false;

    const neededImports = injectImports.imports.filter((importName) =>
      parsedSource.identifiers?.has(importName)
    );

    if (neededImports.length) {
      const imports = neededImports.join(", ");
      const importPath = resolveImportPath(
        relative(dirname(sourcePath), injectImports.from),
        sourcePath
      );
      const importDecl = `import {${imports}} from "${importPath}";\n\n`;

      const injectPos = node.getLeadingTriviaWidth?.(parsedSource) ?? node.pos;
      rewrittenFile.push(file.slice(cursor, injectPos));
      rewrittenFile.push(importDecl);
      cursor = injectPos;
    }
  }

파일 저장하기

마지막으로, 재작성된 소스파일들이 최종적으로 들어갈 대상 폴더에 저장 할 단계입니다. 우선 이미 들어가있는 내용물이 있다면 삭제하고, 그다음 각 파일들을 순서대로 저장하겠습니다.

try {
  await Deno.remove(destDir, {recursive: true});
} catch {}

const sourceFilePathMap = new Map<string, string>();
for (const [sourcePath, destPath] of sourceFilePathMap) {
  // rewrite file
  await Deno.writeTextFile(destPath, rewrittenFile.join(""));
}

Continuous integration

패키지에 대한 Deno 버전이 자동 생성되어 저장되는 별도의 저장소를 만드는것은 일반적인 패턴입니다. 우리의 경우, master에 새로운 커밋이 머지될 때 마다 edgedb-js의 Deno 버전이 Github Actions 을 통해 생성되도록 하였습니다. 생성된 파일들은 edgedb-deno라는 이름의 자매 저장소에 퍼블리시 되게 하였습니다. 아래 코드는 사용된 workflow 파일을 간략히 나타낸 것 입니다.

# .github/workflows/deno-release.yml
name: Deno Release
on:
  push:
    branches:
      - master
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout edgedb-js
        uses: actions/checkout@v2
      - name: Checkout edgedb-deno
        uses: actions/checkout@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          repository: edgedb/edgedb-deno
          path: edgedb-deno
      - uses: actions/setup-node@v2
      - uses: denoland/setup-deno@v1
      - name: Install deps
        run: yarn install
      - name: Get version from package.json
        id: package-version
        uses: martinbeentjes/npm-get-version-action@v1.1.0
      - name: Write version to file
        run: echo "${{ steps.package-version.outputs.current-version}}" > edgedb-deno/version.txt
      - name: Compile for Deno
        run: deno run --unstable --allow-env --allow-read --allow-write tools/compileForDeno.ts
      - name: Push to edgedb-deno
        run: cd edgedb-deno && git add . -f && git commit -m "Build from $GITHUB_SHA" && git push

그러면 edgedb-deno에 등록된 추가적인 workflow가 GitHub 릴리즈를 생성하고, 이를 통해 deno.land/x에 새로운 버전을 퍼블리시 하게 됩니다. 이부분은 독자분들을 위한 실습으로 남겨놓을테니, 우리가 사용한 workflow 파일을 시작점으로 한번 만들어 보시면 좋겠습니다.

마무리

이 글에서 소개한 방법은 Node.js로 작성된 모듈을 Deno로 변환하는 아주 일반화된 패턴입니다. Deno compilation script, cross-workflow에 대한 전체적인 내용을 확인하려면 edgedb-js 저장소를 참고해 주세요.

번역 후기

  • Deno와 Node.js의 모듈을 사용에 있어 최소한의 차이점을 알 수 있어서 좋았습니다.
profile
영어공부

0개의 댓글