DenoとNodeのDual Package開発

# 概要

最近 [dino](https://github.com/keroxp/dino) というminimalなJS用DIライブラリを作りました。このライブラリはES標準の機能のみで作られているのでDeno/Node/Browserのいずれでも動かせます。しかしコードが動いてもDenoとNodeではパッケージのインストール方法が異なるのでライブラリの公開方法を変えないといけません。

というわけで今回はDeno/Nodeの両方で使えるライブラリを管理する方法を考えていきます。

## コードベース

まずDenoとNode.jsの一番の違いとして、モジュールシステムに互換性が無いという点が挙げられます。DenoはESM/TSMのみで構成されており、tsのimportにも対応していますが、Node.jsはCommonJS/ESMという２つの非互換モジュールシステムで構成されています。tsは読み込めません。

ライブラリを公開するにあたってモジュールシステムに互換性がないというのはかなり面倒です。なぜかというと、Deno/NodeのDual Package構成にしようとすると、単一構成のプロジェクトのように普通にコードを書いただけでは対応が出来ません。Deno形式で書いたコードはnpmに公開してもimportできませんし、Node.js形式で書いたものも同様です。

ですので、いずれにしても公開時に何かしらの工夫をしなくてはいけません。

## 公開先

Nodeの公開先は基本的には[npm](https://www.npmjs.com/)になります。最近では[Github Package Registry](https://docs.github.com/ja/free-pro-team@latest/packages/publishing-and-managing-packages/about-github-packages)なども選択肢として挙げられます。

Denoの公開先は[https://deno.land/x](https://deno.land/x#info)が一番簡単です。

## アプローチその1: CDNに任せる

この方法が最も簡単です。まずNode/TypeScriptで普通にパッケージを作り、npmに公開します。そのうえで、Denoから読み込む際に[jspm.io](https://jspm.org/)などのCDNから読み込むという方法です。jspm.ioはCommonJSのnpmパッケージをESMに変換してくれるCDNで、ランタイムでNodeへの依存がなければNodeパッケージもDenoで読み込めます。

難点としては、npmへ公開したパッケージの、TypeScriptファイルを直接CDNからimportすることが出来ないという点です。jspm.ioがESM変換を行ってくれるのはpackage.jsonの`main`から参照されているJSファイルのみなので、Denoで普通に読み込んでもTypeScriptの型定義が読み込めません。

ちなみにこれはJSファイルに[Denoのtriple slash directive](https://deno.land/manual/getting_started/typescript#triple-slash-reference-directive-in-javascript-files)を記述しておけば解決できるかもしれません。

また、明示的にDeno用のファイルを用意しないので、Denoistとしては激おこ案件かもしれません。

## アプローチその2: バンドル

2つ目のアプローチは公開ファイルをバンドルする方法です。Denoで書いたファイルをバンドルしてNode.js向けにする、またはNode.jsで書いたファイルをバンドルしてDeno向けにするかのいずれかになります。この方法では、Denoで書いてもNodeで書いてもいいのでかなり楽な方法になります。このアプローチではバンドリングの手法がより整っているNode.jsの方で記述することが好ましいでしょう。

注意点としては、

- バンドルする側にはプラットフォームに合わせて型定義の参照をバンドルファイルに残しておく必要がある
- Deno向けバンドルファイルはESM、Node向けバンドルは`.mjs`になっている必要がある

などの点が挙げられます。また、このアプローチでは開発上バンドルしない方のプラットフォームを優遇するためバンドルする方の開発体験を向上するために面倒なことが多くなるかもしれません。


## アプローチその3: mjs

3つ目のアプローチは、Deno/Nodeの区別なくコードをmjs（JS/ESM)で記述することです。以下に例を示します。

```js:mod.mjs
/// <reference types="./mod.d.ts" />
import { other } from "./other.mjs"

export function some() {
  return other();
}
```

このように記述したJSファイルは、見た目上DenoとNodeの区別はなく、特別なことをせずにどちらでもそのまま動きます。ブラウザでもimportできるでしょう。
Deno/Node対応JSには、3つの注意点があります。

### 1. JSファイル拡張子を`.mjs`にする

JSで書いたファイルを、`.mjs`拡張子にします。mjsはNode.jsがCommonJSとESMを両立するために策定した悲しき拡張子で、jsファイルがCommonJSではなくESMで記述されているということを明示する拡張子です。

mjsファイルの中には `module.exports`や`require`は記述できませんので、モジュールシステム上は、ESMであることが保証されます。そしてDenoでも`.mjs`拡張子はサポートされているので（Denoの場合`.js`でもESM限定ですが）、JSとして実行が可能になります。


### 2. import文のｍodule識別子をDeno形式で書く

module識別子とは、

```js
import { other } from "./other.mjs"
```

の、 `./other.mjs` の部分のことです。つまりファイルパスですね。Node.jsのモジュールシステムではCJS/ESMどちらでも `./other` という識別子でimportが可能ですが、これだとDenoでは読み込めないので拡張子を省略せずに記述することでDeno Friendlyにします。

### 3. 型定義の用意

使う側がtsの場合を考慮して、mjsファイルごとに型定義ファイルを準備します。

```txt
mod.mjs  // モジュール
mod.d.ts // 型定義
```

このようなファイル構成になっている場合、Node.jsプロジェクトでは `mod.mjs`をimportした場合自動的に`mod.d.ts`が読み込まれて補完が効くと思います（エディタによる？）。また、Denoのコンパイル時の型定義参照として、tsの[triple slash directive](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html)を記述します。


```
/// <reference types="./mod.d.ts" />
```

これでファイルごとに型定義のマッピングができます。

### なぜTSではダメなのか？

ESMの縛りであれば、tsconfigのmodule設定をesmoduleにしてNodeで書けば良いのではないかと思われるかもしれませんが、Node.js形式のESMでは、モジュール識別子にts拡張子をつけることは出来ません。

```ts
// Denoでは有効、Nodeでは無効
import "./other.ts"
```

Nodeで有効な識別子を記述するには以下のようにしますが、

```ts
import "./other"
```

これはtscでのコンパイル後も拡張子が無いままになってしまい、Denoで読み込めません。なので、どのアプローチでも同じTSファイルでDeno/Nodeに対応させる書き方は現状ありません。一つの例外を上げるとすれば、import文を使わず、全ての内容を１つのtsファイルに書くことでしょうか。

## まとめ

DenoとNodeのDeual Package開発のアプローチを3つ紹介しました。**まともな方法が１つもない**ということがおわかりいただけたでしょうか。そもそもDeno/Nodeの両対応パッケージというものにどういう需要があるのか？という問題もあるのですが、せっかくPure ECMA Scriptで書いたコードが有るのにどちらでも使えるようにする普通の方法がない現状はどうなのかと思うわけです。

DenoとNodeのモジュールの非互換性というのはコードの再利用という観点からはかなり深刻な問題で、どこかの時点で何かしらの統合があっても良いのかなと思います。アプローチ3のように`mjs`で記述すればいいのですが、やはりTypeScriptを使いたい…