Denoの新機能「npmインポート」について予習する

Deno v1.25以降、npmインポートが使えるようになります！この機能を使うとNode.js向けのライブラリがDenoでも動作します。
現在は実験的API（実行に--unstableフラグが必要）となっていますが、今後3か月でサポートを強化し、使えるnpmライブラリを増やしていくとのこと。

https://deno.com/blog/changes

実はnpmインポート機能の導入は突然決まったものではなく、今年の2月からryan dahlがやりたいと言っていた機能でした。半年以上かけて議論してやっと導入された感じですね。
またDenoのNode.js互換性自体はBunの登場よりも早い時期（2019年）から進められていて、今回満を持して登場ということになります。

https://github.com/denoland/deno/issues/13703

この記事ではnpmインポートの概要と実装、将来について解説していきます。

## Denoの npmインポート とは

npmから始まるURLをimport文に指定すると、npmパッケージが使えるようになる機能です。

```ts
import express from "npm:express@5";
//                   ^ npm:<パッケージ名>@<バージョン>
```

上記のように書くと、expressパッケージがDenoで動作します。
このときexpressパッケージはNode.jsのAPIがポリフィルされた環境で動きます。もちろんその中ではNode.js標準ライブラリや`require`などが使えるため、問題なく動くというわけです。

下の画像（例）のように、

- npmインポートする側（ライブラリを使う側）のコード：これまで通りのDenoで実行される
- npmインポートされる側のライブラリ：Node.jsポリフィル環境で実行される

という形になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/562fc213-6479-83ad-4552-9fd032613033.png)



[公式ブログ投稿](https://deno.com/blog/changes)の中では3か月以内に90%のnpmパッケージが動作するようになる予定だと書かれています。

:::note warn
npmインポートが追加されると同時に、**これまで実験的にサポートされていた`--compat`フラグ（Node.js互換モード）は削除**されています。

今回削除された`--compat`フラグは「`node`コマンドを`deno run --compat`に差し換えるだけで動く」というモードで、Node.jsユーザーが手軽に＆徐々にDenoに移行することを支援するためのものでした。
しかし`--compat`フラグを使うとDenoの挙動とNode.jsの挙動が混じってしまうので、例えば

- `setTimeout()`の挙動はNode.jsとDeno（ブラウザ）のどちらに合わせたらいいのか
- サーバー環境とブラウザ環境をif文で分岐しているライブラリの挙動はどうなるのか

といった互換性の問題が出てきました。

また、Node.jsの挙動を模倣することから`node_modules`にライブラリをダウンロードしなければ動かないという問題もあり、「結局npmを使用することになるのではないか？」という懸念も出ていました。
そこでDenoは方針転換をしてnpmインポートを実装し、

- npmインポートで呼び出されるライブラリはNode.jsの挙動
- npmインポートを呼び出す側のコードはDenoの挙動（ESM）

という形で、NodeとDenoの挙動が変わる境界をしっかり分けるようになりました。
この方針変更によって**Denoの特徴であるESM Firstの世界を保ったまま、既存のNode.jsライブラリを使える**ようになりました。
:::

以下にnpmインポートの特徴を紹介します。

### node_modulesが作られない

npmインポートを使う際に、事前にnode_modulesフォルダにライブラリをダウンロードしておく必要はありません。
npmインポートを含むファイルを実行すると、**裏側でDenoのキャッシュディレクトリにライブラリがダウンロードされます**（つまり他のDenoのライブラリと同じように使える）。
プロジェクトごとにダウンロードされるのではなく、グローバルな場所にキャッシュされるという点では、pnpmの挙動に近いと思います。

> On Linux/Redox: `$XDG_CACHE_HOME/deno` or `$HOME/.cache/deno`
On Windows: `%LOCALAPPDATA%/deno` (`%LOCALAPPDATA%` = `FOLDERID_LocalAppData`)
On macOS: `$HOME/Library/Caches/deno`
If something fails, it falls back to `$HOME/.deno`
https://deno.land/manual@v1.25.1/linking_to_external_code#linking-to-third-party-code

### Node.js標準ライブラリに対応

Node.jsの標準ライブラリ（例：`net`や`fs`）は大体ポリフィルが実装されています。
そのため`require("fs")`とかを使ったコードがちゃんと動きます。

一点注意が必要なのは、[READMEに載っているリスト](https://github.com/denoland/deno_std/tree/feba1819a9592da746f70fc2670251aea3123a35/node)の通り、非推奨のモジュールや実験的なモジュールはポリフィルされる予定がないということです。（例：`async_hooks`や`wasi`）
[公式ブログ投稿](https://deno.com/blog/changes)が`今後 3 か月以内に npm パッケージの 80 ～ 90% を Deno で動作させることができます`という控えめな表現になっているのは、このあたりが原因だと思われます。

ちなみにポリフィル実装はNode.js用のテストスイートを使ってテストされていて、現在はポリフィルをどんどん追加していってテストの合格率を上げている最中です。

https://github.com/denoland/deno_std/blob/feba1819a9592da746f70fc2670251aea3123a35/node/_tools/config.json


### N-APIに対応予定

N-APIというのはNode.jsに存在するネイティブアドオン用のAPIです。
DenoにN-APIのポリフィルを入れるPRが実装中で、この実装が完了するとN-APIを使っているnpmライブラリがDenoでも動作するようになります。

https://github.com/denoland/deno/pull/13633

## Denoのnpmインポートの仕組み

続いては、Denoでnpmライブラリが動く仕組みに迫っていきたいと思います。

#### npmライブラリのダウンロード

まず行われるのがnpmライブラリのダウンロードです。
実装は[cli/npm](https://github.com/denoland/deno/tree/f6636d41450fd6d0d3d5d6fc6f4c6ead2a639814/cli/npm)ディレクトリで行われており、対象ライブラリを https://registry.npmjs.org からダウンロードしたり、package.jsonから依存関係を解決したり、semverを解決したりといった処理が書かれています。

https://github.com/denoland/deno/tree/f6636d41450fd6d0d3d5d6fc6f4c6ead2a639814/cli/npm

#### グローバル変数の挿入

Node.js固有の標準ライブラリ（`fs`など）やグローバル変数（`setTimeout`など）は標準ライブラリで定義されています。

https://github.com/denoland/deno_std/tree/feba1819a9592da746f70fc2670251aea3123a35/node

npmインポートされたライブラリを実行する際に、これらのポリフィルを流し込んでやる必要があります。この処理は[ext/node/01_node.js](https://github.com/denoland/deno/blob/50fc5d169aec43b4cc0c4ff08a8073bca99c18f4/ext/node/01_node.js#L92-L100)や[cli/node/mod.rs](https://github.com/denoland/deno/blob/264ad49e18161a29cf8831dff2e4bcbcea59d086/cli/node/mod.rs)で行われています。

https://github.com/denoland/deno/blob/50fc5d169aec43b4cc0c4ff08a8073bca99c18f4/ext/node/01_node.js#L92-L100

https://github.com/denoland/deno/blob/264ad49e18161a29cf8831dff2e4bcbcea59d086/cli/node/mod.rs

#### require()の定義

require関数はモジュールの読み込みに関与するため、他のグローバル変数とは別枠で定義されています。[ext/node/02_require.js](https://github.com/denoland/deno/blob/5268fa0e0f34571f0fc615eb665747863aca311e/ext/node/02_require.js#L775)です。

https://github.com/denoland/deno/blob/5268fa0e0f34571f0fc615eb665747863aca311e/ext/node/02_require.js#L775

さらに中身を読んでいくと時々`ops.op_hogehoge()`という関数呼び出しをしているのがわかります。これはRust側の関数を呼び出していて、[ext/node/lib.rs](https://github.com/denoland/deno/blob/264ad49e18161a29cf8831dff2e4bcbcea59d086/ext/node/lib.rs)や[ext/node/resolution.rs](https://github.com/denoland/deno/blob/5f251b283b3532849862c7fdf4b91f0f9ed68829/ext/node/resolution.rs)の中に具体的なRustの処理が書かれています。

https://github.com/denoland/deno/blob/264ad49e18161a29cf8831dff2e4bcbcea59d086/ext/node/lib.rs

https://github.com/denoland/deno/blob/5f251b283b3532849862c7fdf4b91f0f9ed68829/ext/node/resolution.rs

以上、かなりざっくりですが、npmインポートされたライブラリがどう動くのか追ってみました。

## npmインポートは既に使える

npmインポートは8月リリースのv1.25から実装されており、既に試すことができます。

試しにaxiosを使っています。

```ts
import axios from "npm:axios";
console.log(axios.get(await "https://www.yahoo.jp"));
```

```shell
> deno run ./tmp.ts
Download https://registry.npmjs.org/axios
Download https://registry.npmjs.org/follow-redirects
Download https://registry.npmjs.org/form-data
Download https://registry.npmjs.org/asynckit
Download https://registry.npmjs.org/combined-stream
Download https://registry.npmjs.org/mime-types
Download https://registry.npmjs.org/delayed-stream
Download https://registry.npmjs.org/mime-db
Download https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz
Download https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz
Download https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz
Download https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz
Download https://registry.npmjs.org/axios/-/axios-0.27.2.tgz
Download https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz
Download https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz
Download https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz
<ref *1> [Function: wrap] {
  request: [Function: wrap],
  getUri: [Function: wrap],
  delete: [Function: wrap],
  get: [Function: wrap],
  head: [Function: wrap],
  options: [Function: wrap],
  post: [Function: wrap],
  postForm: [Function: wrap],
  put: [Function: wrap],
  putForm: [Function: wrap],
  patch: [Function: wrap],
  patchForm: [Function: wrap],
  defaults: {
...（以下略）
```

…という感じで、依存関係も含めてきちんと初回実行時にダウンロードされ、正しくimportできているのが分かると思います。

現時点ではリリース直後という事もあって細かいバグが多いらしいのですが、エラーがあったら[issue](https://github.com/denoland/deno/issues)に投稿してくれと公式ブログ投稿に書いてあったので、みんな投稿しましょう。


## npmインポートの今後

もともとDenoにおいてはnpmライブラリは https://esm.sh 経由や https://www.skypack.dev/ などのnpm CDN経由で使用できたのですが、この方法だとライブラリによっては依存関係を解決できなかったり、setTimeoutの違いが原因でハングしたり、ブラウザとサーバー側をif文で分岐する個所で変な分岐に入ってしまったりと、微妙に動かないことがありました。
今回導入されたnpmインポートでは完璧なNode.js環境が再現される（はず）で、そういった互換性の問題も解消できると思われます。

:::note warn
一方で、npmインポートはDenoの固有の機能なのでブラウザ互換性の問題があり、特にライブラリの制作者はnpmインポートと既存のnpm CDNをうまく使い分けていく必要があります。（今のところは）
現実的な解としては、まずesm.shを試し、動かなければブラウザ互換をあきらめてnpmインポートを使うのがいいと思います。
（参考：https://github.com/denoland/deno/issues/13703#issuecomment-1218146761 ）
:::

またNode.jsにありがちなCJS / Fake ESM(疑似ESM) / Native ESMの互換性問題やTypeScriptの拡張子問題などを、気にすることなく動かせるという点で、Node.jsよりもライブラリが使いやすくなると言えると思います。

2022年8月15日の公式ブログ投稿では「完成まであと3か月」と言っているので、今年の終わりには普通にnpmパッケージが動作するようになっているはずです。lsp対応もこれから行われるようで、エディタとの連携（特にTypeScript周り）が進んでいくのではないかと思います。

:::note
（2022/9/20追記）コアチームメンバーの方のissue投稿によると、今後は

- `deno info`などの各種サブコマンドへの対応
- ロックファイル対応
- ./node_modulesディレクトリをDENO_DIRにシンボリックリンクして互換性を向上するオプションの導入
- TypeScriptサポート（現在はimportしたものが全てany型扱いになっている）
- エディタ連携対応（LSPサポート）
- エラーメッセージの改善
- 主要モジュールの動作保証

が順次行われるようです！

https://github.com/denoland/deno/issues/15960
:::

## まとめ

この機能の魅力は、なんといっても既存のNode.jsライブラリをDenoのパーミッション管理の下で動かせるという点になります。
これからに期待したいと思います！