ブラウザ, Node, Cloudflareでも動くDenoモジュール開発
ブラウザだけでなく、非ブラウザなJavaScript(JS)処理系も増えてきたので、「主要ブラウザ、Node.js、Cloudflare Workersでも動くDenoファーストなTypeScript/JavaScriptモジュール開発&CI/CD」をやってみました。一事例として共有します。
はじめに
最近、TypeScriptで hpke-js というモジュールをつくりました。
HPKE (Hybrid Public Key Encryption) というのは、ざっくり言うと、公開鍵を交換しあって共有鍵をつくり、安全にEnd-to-End暗号化をおこなうための規格です。これを Web Cryptography API 上に実装し、このAPIをサポートする複数のJS処理系(主要なWebブラウザ、Node.js、Deno、Cloudflare Workers)で動作保証する(ちゃんと全環境でのテストをCI/CDに組込む)ことを目的の1つにしていました。
当初はnpmパッケージとして作り始めたのですが、Deno対応を行う過程で、Denoファーストな構成に大幅に書き換えました。これにより大分すっきりと Chromeでも Firefoxでも Safariでも Node.jsでも Cloudflare Workersでも Denoでも動くTypeScript/JavaScriptモジュールの開発・CI/CDフローが大枠で組めました。
具体的には、コードベースをDeno向けにし、フォーマッタ・リンタ・テストもDenoのビルトインに寄せます。dnt (Deno to Node Transform) を使って、ESM形式のコードを含むnpmパッケージを自動生成し、生成コードのテストもdntにおまかせします。ブラウザでのテストは生成したESMコードをリンクしたテストコンテンツをGithub Pagesに流して playwright/test します。Cloudflare向けには、wrangler でテスト用のworkerをローカルに立ち上げます。概略図は以下。
これを、Pull Request時やマスタ―ブランチへのmerge時にデプロイ以外のフローを、リリース時にはデプロイを含めたフローをGithub Actionsで実行するようにします。
以下、本編では上記のフローを組むためのDenoファーストな「JS処理系非依存モジュール」開発の定義と概要、利用する各種ツールとその設定、Github上でのCI/CDについて hpke-js の例を引きつつ紹介します。
なお、本記事は Web Cryptography API のようなJS処理系が提供するAPIを使いながらもポータビリティを担保したいモジュール向けの話です。処理系に非依存であることが自明ならば、必ずしもここで紹介するようなCIを組む必要は無いでしょう。
前提
本記事での「JS処理系非依存モジュール」とは、リリース後に、各JS処理系で以下のように利用できるモジュールを指すものとします。
ブラウザ: 主要なCDNサービス(esm.sh や Skypack)から、ブラウザ上でESM形式で利用できる。Chrome(Blink系)、Firefox(Gecko系)、Safari(WebKit系)でリリース前にテストされ、動作保証されている。
<script type="module">
import * as hpke from "https://esm.sh/hpke-js@0.13.0";
// import * as hpke from "https://cdn.skypack.dev/hpke-js@0.13.0";
</script>
Node.js: npm
や yarn
でインストールし、ESM形式でも CommonJS形式でも利用できる。サポートを謳う全Node.jsバージョンでテストされ、動作保証されている。
// CommonJS
const hpke = require("hpke-js");
// or ESM
// import * as hpke from "hpke-js";
Deno: 主要なレジストリ(deno.land
とnest.land
)から利用できる。サポートを謳うメジャーバージョン(1.x
)の最新リリースでテストされ、動作保証されている。
import * as hpke from "https://deno.land/x/hpke@0.13.0/mod.ts";
// import * as hpke from "https://x.nest.land/hpke@0.13.0/mod.ts";
Cloudflare Workers: 各種CDNからのダウンロードするなり deno bundle
を使うなりして、シングルファイル化したモジュールをCloudflare Workerのパッケージに含めて利用できる。wrangler
を使い、ローカル環境でテストされ、動作保証されている。
# download from a CDN (esm.sh)
curl -o $YOUR_PATH/hpke.js https://esm.sh/v86/hpke-js@0.13.0/es2022/hpke-js.js
# or downlaod a minified version from a CDN
curl -o $YOUR_PATH/hpke.js https://esm.sh/v86/hpke-js@0.13.0/es2022/hpke.min.js
# or use `deno bundle`
deno bundle https://deno.land/x/hpke@0.13.0/mod.ts > $YOUR_PATH/hpke.js
// then import and use it
import * as hpke from "./hpke.js";
JS処理系非依存モジュール開発
冒頭で触れたように、肝は Denoモジュールとして開発して、dnt (Deno to Node Transform) を使って他のJS処理系で動くコードに変換することです。
公式ドキュメント(dntのREADMEやdocumentation)を読んで、ポータビリティを意識して開発しましょう、に尽きるのですが、主な留意点を私見で以下に挙げておきます。
- Denoネームスペースの機能などポータビリティに影響のあるものは極力使わないようにしたいが、使わざるをえない場合には、dntでの変換時に注入されるshimが備わっているかを確認する(node_deno_shims 参照。例えば、Denoネームスペースのshimの実装状況は、ここで確認できる)。shimを使うことでNode.jsでの動作保証は可能になる。
- 依存パッケージがある場合、なるべく esm.sh か Skypack を使う。この2つは、対応するnpmパッケージがある場合には、dntが、出力するpackage.json の dependencies にマッピングしてくれる。すなわち外部モジュールとして扱ってくれるようなので。
- モジュールのエントリポイントは、Denoでの慣習にならい
mod.ts
にする。せっかく Denoファーストで作るので。 - deno.landでのバージョニングには、gitのtagが使われるので、tag名を SemVer 準拠にしておく(
1.2.3
など)。v1.2.3
でもいいのですが、こうすると各種CDNでのバージョン指定方法の一貫性がなくなってしまう(vが付いたり付かなかったり)ので、無しがおすすめ。 - CommonJS/UMD形式のモジュールを出力したい場合には、Top-level awaitは使わない。
※ 言わずもがなですが、shimという緩和策・回避策が提供されているとはいえ、大前提として、標準化されていない処理系独自機能を使ってしまうと基本的にポータビリティは確保できない点にご留意ください(7/3追記)
レジストリへの登録
JS処理系非依存モジュールを開発するにあたり、あらかじめ以下の2つのレジストリ(npmjs.comとdeno.land)にモジュールを登録しておきます。
npmjs
への登録は必須で、ここにデプロイすると、各種CDN(esm.sh、Skypack、unpkg.com など)へもデプロイされることになります。
Denoモジュールとしては、やはりdeno.land
で配るようにはしておきたいですね。上記リンクから Publish a module
をクリックし、手順に従えば登録できます。Githubリポジトリが必要な点にご注意ください。ちなみに本記事では、Denoモジュールをdeno.land
だけでなく、nest.land
にも登録してみます。nest.land
はブロックチェーンベースのimmutableなレジストリらしいです。
これも留意点の1つですが、モジュール名を決めたら、上記レジストリのいずれにも登録されていないことを確認したうえで、事前におさえておいたほうがよいでしょう(私はこれに失敗・・)。
ディレクトリ構成
ここから中身の話に入っていきます。次節で各種ツールと設定について紹介しますが、その前に hpke-jsのディレクトリ構成と主要なファイルを晒しておきまます。
従来は、package.json, package-lock.jsonに加えて、eslint、jest、typescript、typedocの設定ファイル、esbuild用のスクリプトなんかも加わってゴチャゴチャしがちでしたが、Denoファーストにすると、多少すっきりします。トップディレクトリにある設定ファイルは以下の4つ。egg.json
は正直どうでもいいので、実質3つです。
-
deno.json
(deno用設定) -
dnt.ts
(dntの設定&実行スクリプト) -
import-map.json
(依存ライブラリのバージョン記述集約のため) -
egg.json
(nest.land
へのデプロイ用。deno.land
だけでいいなら必要なし)
.
├── deno.json
├── dnt.ts
├── egg.json
├── import-map.json
├── mod.ts
├── README.md
├── src
│ └── *.ts
└── test
├── *.test.ts # 単体テスト。Deno向けだが、変換のうえ他の処理系向けにも実行される
├── pages # ブラウザでのe2eテスト用コンテンツ
│ ├── index.html
│ └── src
├── playwright # ブラウザを使ったe2eテスト
│ ├── hpke.spec.ts
│ ├── package.json
│ └── playwright.config.ts
└── wrangler # Cloudflare Workers用e2eテスト
├── hpke.spec.ts
├── package.json
├── src
│ └── index.js
└── wrangler.toml
利用ツールと各設定
以下について書きますが、インストール方法や基本的な使い方の説明はしません。それぞれ公式ドキュメントを参照してください。基本的には、私の設定を晒してポイントを紹介する程度です。
- deno
- dnt
- playwright/test
- wrangler
- eggs
deno
denoには、フォーマッタ(fmt)、リンタ(lint)、テスト(test)、ドキュメンティング(doc)がビルトインされていて良いですよね。Cargo
的というか今どきな感じです。
denoの設定ファイル deno.json
はあくまでオプションで、別に無くても良いのですが、開発効率を考えると、開発やCIで使う一連のコマンドは、tasksなどに登録しておいたほうが良いと思います。
まずは hpke-js/deno.json を晒します。
{
"fmt": {
"files": {
"include": [
"README.md",
"CHANGES.md",
"deno.json",
"dnt.ts",
"egg.json",
"import-map.json",
"samples/",
"src/",
"test/"
],
"exclude": [
"samples/node/node_modules",
"samples/ts-node/node_modules",
"src/bundles",
"test/playwright/node_modules",
"test/wrangler"
]
}
},
"lint": {
"files": {
"include": ["samples/", "src/", "test/"],
"exclude": [
"samples/node/node_modules",
"samples/ts-node/node_modules",
"src/bundles",
"test/playwright/node_modules",
"test/wrangler"
]
}
},
"importMap": "./import-map.json",
"tasks": {
"test": "deno fmt && deno lint && deno test test -A --fail-fast --doc --coverage=coverage --jobs --allow-read",
"dnt": "deno run -A dnt.ts $(git describe --tags $(git rev-list --tags --max-count=1))",
"cov": "deno coverage ./coverage --lcov --exclude='test' --exclude='bundles'",
"minify": "deno bundle ./mod.ts | esbuild --minify"
}
}
- ポイント
-
fmt
は、markdown, jsonにも対応しているので、README.mdなども対象にする - e2eテスト等にnpmを使うものがあるので
fmt
とlint
対象からnode_moduleを除外 - imprt-mapを使うなら
"importMap": "./import-map.json"
は必須 -
tasks
のtest
で、deno fmt
もdeno lint
も一括実行してしまう -
tasks
のdnt
で、package.jsonに入れるバージョンを$(git describe..)
で指定する
-
dnt
dntは、Deno向けのコードからnpmパッケージを作るビルドツールです。公式(READMEやdocumentation)を見ていただくのが一番ですが、hpke-js/dnt.ts を一例として晒します。
import { build, emptyDir } from "dnt";
await emptyDir("./npm");
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
typeCheck: true,
test: true,
declaration: true,
scriptModule: "umd",
importMap: "./import-map.json",
compilerOptions: {
lib: ["es2021", "dom"],
},
shims: {
deno: "dev",
},
package: {
name: "hpke-js",
version: Deno.args[0],
description:
"A Hybrid Public Key Encryption (HPKE) module for web browsers, Node.js and Deno",
repository: {
type: "git",
url: "git+https://github.com/dajiaji/hpke-js.git",
},
homepage: "https://github.com/dajiaji/hpke-js#readme",
license: "MIT",
main: "./script/mod.js",
types: "./types/mod.d.ts",
exports: {
".": {
"import": "./esm/mod.js",
"require": "./script/mod.js",
},
"./package.json": "./package.json",
},
keywords: [
"hpke",
// ...省略
],
engines: {
"node": ">=16.0.0",
},
author: "Ajitomi Daisuke",
bugs: {
url: "https://github.com/dajiaji/hpke-js/issues",
},
},
});
// post build steps
Deno.copyFileSync("LICENSE", "npm/LICENSE");
Deno.copyFileSync("README.md", "npm/README.md");
- ポイント
-
scriptModule: "umd"
でUMD形式のコードも出力するようにする - import-mapを使う場合、
importMap: "./import-map.json"
は必須。
-
playwright/test
今回、playwright/test を初めて使いましたが、今どきはブラウザを使ったE2Eテストがホントに簡単にできてしまうのですね。
hpke-js/test/playwright/playwright.config.ts は以下の通り。
import { devices, PlaywrightTestConfig } from "@playwright/test";
const config: PlaywrightTestConfig = {
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
};
export default config;
とりあえず、chromium
、firefox
、webkit
であらかた網羅できているのではと思っています。
テストコード(hpke-js/test/playwright/hpke.spec.ts)は以下。9行だけ。
import { expect, test } from "@playwright/test";
test("basic test", async ({ page }) => {
await page.goto("https://dajiaji.github.io/hpke-js/");
await page.click("text=run");
await page.waitForTimeout(5000);
await expect(page.locator("id=pass")).toHaveText("45");
await expect(page.locator("id=fail")).toHaveText("0");
});
基本的に、モジュールの機能は、ユニットテストである程度網羅的に確認できているので、実環境を使ったE2Eでは、HPKEの全暗号スイートの組み合わせ(KEM:5種類 * KDF:3種類 * AEAD:3種類 = 45通り)で、一通り Web Cryptography API を触るテストコンテンツを用意し、テストボタンを叩いて結果を見るだけにしています。
wrangler
wrangler は、Cloudflare WorkersのCLIです。
ブラウザ向けのテストと同じにすればよいのですが、せっかくなので、以下のようなインタフェースのtest APIにして、wrangler dev --local=true
で動かし、deno test
でE2Eテストする形にしてみました。前述したplaywright/test
と同様、ユニットテストを信じて、一通り Web Cryptography API の呼び出しを確認する基本的なテストシナリオを実行する程度に留めています。
/test?kem={KEM_ID}&kdf={KDF_ID}&aead={AEAD_ID}
- test APIの実装: hpke-js/test/wrangler/src/index.js
-
deno test
で回すE2Eテスト: hpke-js/test/wrangler/hpke.spec.ts
eggs
eggs は、nest.land
へのデプロイ用のCLIです。設定ファイル(hpke-js/egg.json)は、以下の通り。package.json風にパッケージ情報を記載します。
{
"$schema": "https://x.nest.land/eggs@0.3.4/src/schema.json",
"name": "hpke",
"entry": "./mod.ts",
"description": "A Hybrid Public Key Encryption (HPKE) module for web browsers, Node.js and Deno.",
"homepage": "https://github.com/dajiaji/hpke-js",
"files": [
"./src/**/*.ts",
"./src/**/*.js",
"README.md",
"LICENSE"
],
"checkFormat": false,
"checkTests": false,
"checkInstallation": false,
"check": true,
"ignore": [],
"unlisted": false
}
- ポイント
- バージョン情報を
eggs.json
に定義することもできるのですが、dnt
と同様にコマンド引数で最新のtag情報を渡すようにします(Deliveryのeggs publish
参照)。
- バージョン情報を
Github上でのCI/CD
前節で述べた各種ツールを駆使しつつ、冒頭の概略図を愚直にGithub Actionsに落とし込みます。各ymlファイルを晒します。
- CI for Deno
- CI for Browsers
- CI for Node.js
- CI for Cloudflare Workers
- Delivery
CI for Deno
hpke-js/.github/workflows/ci.yml
CodeCovでカバレッジを可視化するようにしています。
name: Deno CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Run deno test
run: |
deno fmt --check
deno task test
deno task cov > coverage.lcov
- uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.lcov
flags: unittests
CI for Browsers
hpke-js/.github/workflows/ci_browser.yml
pages
ジョブで、テストコンテンツをデプロイし、playwright-test
ジョブでE2Eテストします。
name: Browser CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
pages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- run: |
deno task dnt
cp npm/esm/*.js test/pages/src/
cp -rf npm/esm/src test/pages/src/
- uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
publish_dir: ./test/pages
playwright-test:
needs: pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: microsoft/playwright-github-action@v1
- working-directory: ./test/playwright
run: npm install && npx playwright install && npx playwright test
CI for Node.js
hpke-js/.github/workflows/ci_node.yml
Node.jsの複数のバージョン(16.x
, 17.x
, 18.x
)で deno task dnt
とdeno task minify
を実行しています。
name: Node.js CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 17.x, 18.x]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Run dnt & minify
run: |
npm install -g esbuild
deno task dnt
deno task minify > ./npm/hpke.min.js
ちなみに、Cloudflare Workersのサイズ制限を考えて、少しでもコンパクトなJSファイルを用意しようと、esbuild
でminify
するようにしたのですが、例えば、デプロイ先の1つesm.sh
は、minify済みのJSファイルを作ってくれるので、結果的にはあまり意味が無かったです。hpke-jsの例では、ノーマルサイズ12KB、esbuildによるminify版6KB、esm.sh版6.5KB。
CI for Cloudflare Workers
hpke-js/.github/workflows/ci_cfw.yml
npm start
で wrangler dev --local=true
を実行しています。
name: Cloudflare Workers CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- uses: actions/setup-node@v3
with:
node-version: v16.x
- run: deno bundle mod.ts test/wrangler/src/hpke.js
- name: Run test
working-directory: ./test/wrangler
run: |
npm install
nohup npm start &
deno test hpke.spec.ts --allow-net
Delivery
hpke-js/.github/workflows/publish.yml
npmjs.com
とnest.land
へのデプロイは、このGithub Actionsで実行します。
deno.land
はtag作成のタイミングで、(モジュール登録時に設定した)WebHookに登録したdeno.land
のAPIが呼ばれます。
name: Publish
on:
release:
types: [created]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: v16.x
registry-url: https://registry.npmjs.org/
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Run eggs
run: |
deno install -A --unstable https://x.nest.land/eggs@0.3.4/eggs.ts
eggs link ${{ secrets.NEST_API_KEY }}
eggs publish --yes --version $(git describe --tags $(git rev-list --tags --max-count=1))
- name: Run dnt & minify
run: |
npm install -g esbuild
deno task dnt
deno task minify > ./npm/hpke.min.js
- working-directory: ./npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
以上が一通りのCI/CDの説明になります。
課題
一通りCI/CDフローを組んでみたものの、課題に感じていることを付記しておきます。
-
dependabot
連携が現状できない- Denoファーストで作る際の一番大きなデメリットかなと思っています(私見)。import-map.json内の依存パッケージの更新は dependabotに任せたいです。
-
dnt
による変換時のテストが並列実行できない-
hpke-js
のユニットテストは、標準規格の膨大なテストベクターを使っている関係上、実行に時間がかかるので結構ストレスです。
-
- そもそも、強いJavaScript処理系が多数存在する現状
おわりに
やはりJS処理系がたくさんある現状はつらいですよね。本記事で述べたように、dnt
や Github Actions等を活用することで、ある程度大変さを緩和することはできますが、やはり標準化などの枠組みの中で、もう少しポータビリティを担保しやすい形にもっていってほしいですよね。
ということで、W3C Winter CGには期待しています。
ちなみに私、フロントエンジニアではなく、TypeScriptは今回初めて使ったぐらいのビギナーです。JavaScriptに関してもライトユーザで、ラピッドプロトタイピングでたまに使う程度、"The Good Parts"と"Effective JavaScript"で知識が止まっている状態でした。ということで、この記事自体のTypeScript/JavaScript成分は弱いものの、とりまくエコシステムについても、わかっていないところが多いので、ツッコミどころも色々あるかと思いますが、優しくご指摘いただければ幸いです。
Discussion