이 글은 How we converted our Node.js library to Deno (using 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로 작성된 라이브러리를 컨버팅 할 때 고려해야할 Node.js와 Deno 런타임의 핵심 차이점이 몇가지 있습니다.
require/module.exports
문법을 사용하는 CommonJS 포맷으로 모듈을 임포트 합니다. 또한 node_modules 폴더에서 "react"와 같은 단순(plain) 이름을 통한 로딩 방법, 확장자 없는 import에 .js
나 .json
등 자동으로 필요한 확장자를 붙이는 방법, 그리고 경로를 통한 import에서 index.js
파일을 불러오는 등 복잡한 resolution algorithm을 사용합니다.import
와 export
모듈 문법을 사용해 이를 획기적으로 간단하게 만들었습니다.node_modules
폴더나 npm
과 yarn
같은 패키지 매니저가 더이상 필요 없음을 의미합니다. 즉, 외부 모듈은 URL이나 deno.land/x나 GitHub와 같은 공개 코드 저장소에서 직접적으로 import 됩니다.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을 사용중).Deno
라는 이름의 단일 전역 변수에 저장하고 있으며, 나머지는 모두 표준 web API들입니다. Node.js와는 다르게, Buffer
와 process
와 같은 전역 변수 사용이 불가능 합니다.그렇다면 어떻게 이러한 차이점을 해결하면서도 최대한 단순하게 Node.js 라이브러리가 Deno에서 돌아가게 할 수 있을까요? 어떻게 바꾸어야 하는지 하나씩 알아보겠습니다.
다행히도, CommonJS require/module.exports
문법을 ESM import/export
스타일로 바꾸어야 하는 부분은 크게 걱정할게 없습니다. edgedb-js
는 온전히 TypeScript로만 작성되었는데, 이미 ESM 문법을 사용하고 있습니다. tsc
가 생성한 순정 JavaScript 파일들을 Node.js가 사용할 수 있도록 컴파일 과정에서 CommonJS require
문법으로 변환되는 형태입니다.
이 글의 나머지 부분에서는 TypeScript 소스코드를 어떻게 Deno에서 바로 사용될 수 있는 형태로 바꾸었는지에 대해서 이야기해 보겠습니다.
다행히도, 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.
즉, 코드를 수정하는 스크립트를 뜻함.
제작에 들어가기 앞서, 우리가 만들 툴이 어떤 일들을 해야 하는지 그림을 그려보겠습니다.
.ts
확장자와 /index.ts
를 붙이는 것 포함.adapter.node
에 대한 모든 import를 adapter.deno.ts
로 교체.process
, Buffer
와 같은 Node.js 전역변수를 Deno화된 코드에 삽입. Adapter 에서 이 변수들을 export 할 수도 있지만, Node.js 파일들이 명시적으로 import하게 리팩토링 하기로 함. 간결성을 위해, Node.js 전역값이 사용된 부분을 발견할 때마다 해당 파일에 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);
}
지금까지는 꽤 쉬운 내용이었습니다. 이제 소스코드 내용을 수정해 봅시다.
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 전역 변수를 처리입니다. 우선, 프로젝트 폴더에 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(""));
}
패키지에 대한 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
저장소를 참고해 주세요.