🥚

ブラウザ, Node, Cloudflareでも動くDenoモジュール開発

2022/07/02に公開

ブラウザだけでなく、非ブラウザなJavaScript(JS)処理系も増えてきたので、「主要ブラウザ、Node.js、Cloudflare Workersでも動くDenoファーストなTypeScript/JavaScriptモジュール開発&CI/CD」をやってみました。一事例として共有します。

はじめに

最近、TypeScriptで hpke-js というモジュールをつくりました。

https://github.com/dajiaji/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をローカルに立ち上げます。概略図は以下。

Overview

これを、Pull Request時やマスタ―ブランチへのmerge時にデプロイ以外のフローを、リリース時にはデプロイを含めたフローをGithub Actionsで実行するようにします。

以下、本編では上記のフローを組むためのDenoファーストな「JS処理系非依存モジュール」開発の定義と概要、利用する各種ツールとその設定、Github上でのCI/CDについて hpke-js の例を引きつつ紹介します。

なお、本記事は Web Cryptography API のようなJS処理系が提供するAPIを使いながらもポータビリティを担保したいモジュール向けの話です。処理系に非依存であることが自明ならば、必ずしもここで紹介するようなCIを組む必要は無いでしょう。

前提

本記事での「JS処理系非依存モジュール」とは、リリース後に、各JS処理系で以下のように利用できるモジュールを指すものとします。

ブラウザ: 主要なCDNサービス(esm.shSkypack)から、ブラウザ上で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: npmyarn でインストールし、ESM形式でも CommonJS形式でも利用できる。サポートを謳う全Node.jsバージョンでテストされ、動作保証されている。

// CommonJS
const hpke = require("hpke-js");
// or ESM
// import * as hpke from "hpke-js";

Deno: 主要なレジストリ(deno.landnest.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のREADMEdocumentation)を読んで、ポータビリティを意識して開発しましょう、に尽きるのですが、主な留意点を私見で以下に挙げておきます。

  • Denoネームスペースの機能などポータビリティに影響のあるものは極力使わないようにしたいが、使わざるをえない場合には、dntでの変換時に注入されるshimが備わっているかを確認する(node_deno_shims 参照。例えば、Denoネームスペースのshimの実装状況は、ここで確認できる)。shimを使うことでNode.jsでの動作保証は可能になる。
  • 依存パッケージがある場合、なるべく esm.shSkypack を使う。この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)にモジュールを登録しておきます。

https://www.npmjs.com/

https://deno.land/x

npmjsへの登録は必須で、ここにデプロイすると、各種CDN(esm.shSkypackunpkg.com など)へもデプロイされることになります。

Denoモジュールとしては、やはりdeno.landで配るようにはしておきたいですね。上記リンクから Publish a moduleをクリックし、手順に従えば登録できます。Githubリポジトリが必要な点にご注意ください。ちなみに本記事では、Denoモジュールをdeno.landだけでなく、nest.landにも登録してみます。nest.landはブロックチェーンベースのimmutableなレジストリらしいです。

https://nest.land/

これも留意点の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.jsonnest.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を使うものがあるのでfmtlint対象からnode_moduleを除外
    • imprt-mapを使うなら "importMap": "./import-map.json"は必須
    • taskstestで、deno fmtdeno lintも一括実行してしまう
    • tasksdntで、package.jsonに入れるバージョンを $(git describe..) で指定する

dnt

dntは、Deno向けのコードからnpmパッケージを作るビルドツールです。公式(READMEdocumentation)を見ていただくのが一番ですが、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;

とりあえず、chromiumfirefoxwebkitであらかた網羅できているのではと思っています。

テストコード(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}

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情報を渡すようにします(Deliveryeggs 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 dntdeno 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ファイルを用意しようと、esbuildminifyするようにしたのですが、例えば、デプロイ先の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 startwrangler 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.comnest.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には期待しています。

https://www.w3.org/community/wintercg/

ちなみに私、フロントエンジニアではなく、TypeScriptは今回初めて使ったぐらいのビギナーです。JavaScriptに関してもライトユーザで、ラピッドプロトタイピングでたまに使う程度、"The Good Parts"と"Effective JavaScript"で知識が止まっている状態でした。ということで、この記事自体のTypeScript/JavaScript成分は弱いものの、とりまくエコシステムについても、わかっていないところが多いので、ツッコミどころも色々あるかと思いますが、優しくご指摘いただければ幸いです。

Discussion