npm_package_config と npm_config

2016年9月14日 2 開発 , , , ,

npm-run-allSupport npm config params という issue から npm_config の存在を知った。以前、npm-scripts 内で変数を展開したくなって cross-conf-env を開発した時に npm_package_config は調べたが、この npm_config も外部から npm-scripts にパラメータを渡す仕組みのようである。

これらの性質や用途について、簡単にまとめてみる。

npm_package_config

package.json の config 欄に解説されている。

A “config” object can be used to set configuration parameters used in package scripts that persist across upgrades. For instance, if a package had the following:

{ "name" : "foo"
, "config" : { "port" : "8080" } }

and then had a “start” command that then referenced the npm_package_config_port environment variable, then the user could override that by doing npm config set foo:port 8001.

package.json の config プロパティに key/value を定義することで npm-scripts 内へ npm_package_config_key と展開する機能。

公式資料では言及されていないのだが、npm-scripts は Shell になるため、macOS などの bash 系は $npm_package_config_key、Windows のコマンドプロンプトや PowerShell では %npm_package_config_key% のように定義する。

Node プログラム上からは process.env.npm_package_config_key として参照可能。

私の作成した cross-conf-env では、これらと修飾文字なしの npm_package_config をサポートしており、混在させても展開するところが特徴。つまり Shell を問わず好きな書式で記述できるようにしてある。

この機能を利用すると npm-scripts 内のパラメータを直値から変数にできる。npm-scripts 間で重複する値があるとか、頻繁に更新される値があるなら変数化して config プロパティ側の編集だけで済ませられるようにしておくと便利だ。

実例は akabekobeko/examples-electron を参照のこと。このリポジトリは複数の Electron プロジェクトを管理しているが、これらの npm-scripts は共通化され config プロパティでアプリ名などを分岐している。

npm_config

config に解説されている。この資料は npm_package_config に対しても言及あり。

npm_package_config と異なり、こちらは npm run される時の引数として渡されたパラメータを展開する。例えば

{
  "scripts": {
    "task": "echo npm_config_foo npm_config_bar"
  }
}

のように npm-scripts を定義して、以下のようにパラメータを渡す。

$ npm run task --foo=Foo --bar=Bar

Foo Bar

npm run されるスクリプトのオプションに --key=value を指定すると、スクリプト内の npm_config_key に展開される。Node プログラム上からは process.env.npm_config_key として参照可能。

package.json 外からパラメータを渡すとか、npm-scripts を多段実行する時に npm_package_config 代わりにするとかで役立つのかもしれない。本記事のきっかけとなった npm-run-all は npm-scripts を同期・非同期で多段実行するための npm なので、これに対応する必要があったのだろう。

npm_config が展開される側は Shell なので前述のとおり にプラットフォームごとの修飾文字が必要。cross-conf-env は v1.0.6 で対応した。

まとめ

私はプロジェクトに関する情報をなるべく package.json へ静的に定義したいので、npm_package_config のほうが好みだ。しかしこちらは config の定義が必要なうえ記述も長い。よって npm-scripts の多段実行は必要になるけれど、より短い npm_config も便利な場面があるかもしれない。

package.json で確定不能な設定については npm_config に頼らざるを得ない。npm_scripts を実行するのは主に開発者なので、このようなケースはあまり考えられないのだが、npm から取得できない環境情報なんかを渡すときによいのだろうか。

ほぼ共通の設定で一部だけ異なる npm-scripts があるときに便利かもしれない。そういえば Browserify の require オプションでモジュールを外部公開するを書いた後に業務で

  • 共通処理を定義した main.js
  • サブフォルダ単位で個別のデータを定義した data.js
  • HTML 上で main.js と組み合わせる data.js を変更することで、ページ内容が切り替えられる

という感じの Web サイトをビルドする機会があって、サブフォルダが増えるたびにその名前だけ変更した npm-scripts を追加する運用を考えていた。

しかしこれは面倒なので npm_config によりフォルダ名を部分展開するほうがスマートな気がする。複数同時に生成するとしても、npm-scripts の多段実行を前提とすれば package.json で完結できる。

記事のまとめに軽く考察でも、と雑に書いていたら業務の問題がひとつ解決してしまった。

jsdoc-to-assert を試す

2016年8月23日 0 開発 , , ,

JavaScript の型チェックといえば FlowTypeScript だが、前者は型の定義ファイルが必要で、後者は言語自体を拡張しているため導入コストが少々、高い。もっと手軽に

  • 追加の外部ファイルは不要
  • JavaScript の組み込み型ぐらいをチェックできればよい

ぐらいのものがあれば、と探してみたら azu/jsdoc-to-assert がそんな感じなので試してみる。

2016/8/24
「省略可能な引数」と「null 許容型」の項を追記。

jsdoc-to-assert

jsdoc-to-assert は JSDoc 形式で書かれた関数のコメントを元に型チェックする。開発の経緯や詳細については JSDocをランタイムassertに変換するBabelプラグインを書いた | Web Scratch を参照のこと。おこなわれる処理は単純で、

  • JSDoc の @param から引数の型と名前を取得
  • 関数の冒頭に引数の型チェックを console.assert 形式で埋め込む

というもの。例えば

/**
 * Output log.
 *
 * @param {String} message Message text.
 */
function func( message ) {
  console.log( message );
}

というコードを jsdoc-to-assert に渡すと

/**
 * Output log.
 *
 * @param {String} message Message text.
 */
function func(message) {
  console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');

  console.log(message);
}

に変換される。型チェックが偽ならば、その情報を assert として出力する。Firefox や Chrome の開発者ツールであれば関数の呼び出しと assert 箇所をコンソールから確認できるので、不正な値を指定した処理を修正するためのヒントになる。

環境構築と注意点

jsdoc-to-assert は本体と Babel plugin/preset 版が提供されている。plugin が Babel 用の機能拡張で、preset はそれをセットでパッケージ化したものになる。

いまのところ preset には babel-plugin-jsdoc-to-assert しか含まれていないので、どちらを選んでも機能差はないはず。とはいえ preset にしておけば依存が増えた時にも対応されるだろうから、今回は babel-preset-jsdoc-to-assert を採用。

以上を踏まえて package.json を定義。

{
  "babel": {
    "passPerPreset": true,
    "presets": [
      "es2015"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert",
          "jsdoc-to-assert"
        ]
      }
    }
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  "scripts": {
    "test": "mocha --compilers js:babel-register test/**/*.test.js",
    "build:": "browserify ./src/js/App.js -d | exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js",
    "watch": "watchify ./src/js/App.js -v -o \"exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js\" -d",
    "release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.13.2",
    "babel-preset-jsdoc-to-assert": "^3.0.2",
    "babel-preset-power-assert": "^1.0.0",
    "babel-register": "^6.11.6",
    "babelify": "^7.3.0",
    "browserify": "^13.1.0",
    "cross-env": "^2.0.0",
    "exorcist": "^0.4.0",
    "mocha": "^3.0.2",
    "npm-run-all": "^3.0.0",
    "power-assert": "^1.4.1",
    "uglify-js": "^2.7.3",
    "watchify": "^3.7.0"
  }
}

Babel の設定は .babelrc に書いてもよい。というか、そのほうが一般的だと思う。私はなるべく package.json にプロジェクト設定を集約したい派なのでこちらへ定義している。Browserify 設定も同様。

Babel 関連の npm をまとめる。

npm 用途
babel-preset-es2015 ES2015 変換用プリセット。
babel-preset-jsdoc-to-assert jsdoc-to-assert を利用するためのプリセット。
babel-preset-power-assert 単体テストで assert を power-assert に置換するためのプリセット。
babel-register Babel 変換時、import/require を補足するためのモジュール。assert の power-assert 置換などに必要。
babelify Browserify 用 Babel プラグイン。

今回は開発用のコードと単体テストの両方で jsdoc-to-assert を試したいので power-assert 関連も利用する。JavaScript 全体の bundle 化用に Browserify を使いたいので、Babel 本体は babelify を採用。Browserify が不要なら babel-cli を選んでもよい。

Babel の設定について。

{
  "babel": {
    "passPerPreset": true,
    "presets": [
      "es2015"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert",
          "jsdoc-to-assert"
        ]
      }
    }
  }
}

冒頭の passPerPreset が重要で、これを true にするとプラグイン単位で変換をハンドリング可能となる。プラグインの実行順で変換結果に問題が起きたときに指定するオプションである。

jsdoc-to-assert を試したとき Object の property として定義された関数などが無視されたので報告したところ、修正報告にこの設定を有効にしてほしいと回答されていたので、そのようにしている。

もうひとつ、jsdoc-to-assert の処理が開発版でだけ動作するように envdevelopment 側に定義している。こうすると NODE_ENV=production のときは除外される。npm-scripts の

{
  "scripts": {
    "release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
  },
}

がその指定。通常、npm-scripts から NODE_ENV は操作できないので cross-env を利用した。この方法は React をリリース用ビルドして余計な開発用コードを除去するなどでも必要なので、個人的に定石となっている。

実行してみる

package.json の定義と型チェック用の JavaScript を用意したら、実行してみよう。

$ npm run build

出力された bundle.js をテキスト エディタで開いてみると、console.assert が挿入されることを確認できるはず。わざと間違った型を指定子したスクリプトを実装して、それを読み込んだ HTML を Web ブラウザの開発者ツールで見てみると

jsdoc-to-assert による assert

こんな感じで assert が出力されている。なお、jsdoc-to-assert v2.4.2 時点では以下の関数が assert 対象となる。

  • キーワード function で定義される通常の関数
  • Object の property として定義された関数
  • ES2015 class の constructor
  • ES2015 class のメソッド
  • ES2015 class の static メソッド
  • ES2015 class の property setter

ES2015 の Arrow Function は対応していないようだが、これはコールバックなどで局所的に使用されることが多く、それに対して JSDoc を書く機会は少ないだろうし、なくてもさほど困らない。

また、型チェックの対象となるのは JavaScript の組み込み型になる。ES2015 class を定義し、それを型として JSDoc の param に指定しても無視される。

もうひとつ注意。jsdoc-to-assert は param が正しく書かれていることを前提としている。よって変数名が間違っていると常に assert がヒットすることになるので、気をつけること。引数の名前を修正するとき JSDoc に反映し忘れるとこの問題に遭遇する。というか、した。

単体テストの assert

単体テストの対象となるコードに jsdoc-to-assert が適用されるとどうなるのか。私の利用している mocha + power-assert の組み合わせで試してみた。

describe( 'Valid', () => {
  describe( 'Sample', () => {
    it( 'constructor', () => {
      const sample = new Sample( 'message' );
      assert( sample.message === 'message' );
    } );
  } );
} );

のような正常系と

describe( 'Invalid', () => {
  /** @test {Sample} */
  describe( 'Sample', () => {
    /** @test {Sample#constructor} */
    it( 'constructor', () => {
      const sample = new Sample( 7 );
      assert( sample.message === 'message' );
    } );
  } );
} );

という異常系を実装してテストを走らせてみる。

$ npm test

> using-jsdoc-to-assert@1.0.0 test .../jsdoc-to-assert
> mocha --compilers js:babel-register test/**/*.test.js



  Valid
    Sample
      ✓ constructor

  Invalid
    Sample
      1) constructor


  1 passing (2ms)
  1 failing

  1) Invalid Sample constructor:

      AssertionError: Invalid JSDoc: typeof message === "string"
      + expected - actual

      -false
      +true

      at Console.assert (console.js:95:23)
      at new Sample (Sample.js:52:26)
      at Context.<anonymous> (Sample.test.js:96:22)

jsdoc-to-assert の埋め込むものは console.assert なので mocha 上の扱いが気になっていたが、普通に failing となった。power-assert のように値の詳細は表示されないものの、位置はわかるので型を修正するヒントとしては十分である。

余談だが、単体テストで実行される関数がどのように変換されたかを知りたい場合は

describe( 'Invalid', () => {
  it( 'logStrStatic', () => {
    Sample.logStrStatic( 'message' );
    console.log( String( Sample.logStrStatic ) );
  } );
} );

のように関数自体を String で文字列化して console.log するとよい。出力結果は

function logStrStatic(message) {
      console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');

      console.log(message);
    }

となる。この例だと jsdoc-to-assert により埋め込まれたコードを確認できる。

省略可能な引数

関数を設計する際、オプション扱いの設定などを省略可能にすることがある。例えば

/**
 * 指定されたファイルにデータを書き込みます。
 *
 * @param {String} path 書き込み対象となるファイルのパス情報。
 * @param {Buffer} data 書き込むデータ。
 * @param {String} mode 書き込みモード。省略時はファイルを新規作成します。
 *
 * @return {Boolean} 成功時は true。
 */
function writeFile( path, data, mode = 'w' ) {
}

という関数があったとする。引数のうち pathdata は必須だが mode は省略可能で、その場合はデフォルトの動作をする仕様。これをそのまま jsdoc-to-assert すると mode の型チェックが挿入されるため、省略したときに assert されてしまう。

Use JSDoc: @param の Optional parameters and default values を読むと省略可能な引数は {String=} のように記述するようだ。しかし jsdoc-to-assert では型として認識されず assert が生成されない。

よって型の列挙を代替案とする。

/**
 * 指定されたファイルにデータを書き込みます。
 *
 * @param {String} path 書き込み対象となるファイルのパス情報。
 * @param {Buffer} data 書き込むデータ。
 * @param {String|undefined} mode 書き込みモード。省略時はファイルを新規作成します。
 *
 * @return {Boolean} 成功時は true。
 */
function writeFile( path, data, mode = 'w' ) {
}

mode の型を String から String|undefined に変更することで、mode が undefined にもなり得ることを示す。これを jsdoc-to-assert すると

function writeFile( path, data, mode ) {
  // ...略
  console.assert(typeof mode === "string" || typeof undefined === "undefined" || mode instanceof undefined, 'Invalid JSDoc: typeof mode === "string" || (\ntypeof undefined === "undefined" || mode instanceof undefined\n)');
}

というコードが生成される。param の型に undefined が登場すると assert 内で常に true となる箇所が埋め込まれる。そのため評価がここに達すれば常に assert を通るわけだ。結果として省略可能な引数の指定に使える。

後続に到達できない instanceof が登場することから、もしかするとバグなのかもしれない。なお undefined のかわりに null を指定したときも同様の assert が生成される。

null の型は object なので、これを列挙される型として判定するなら value !== null のように値そのものが null であることを調べなくてはいけない。

null 許容型

省略可能な引数の扱いを調べていて、そういえば JSDoc 的に null 許容型はどういう扱いなんだろう?とググッてみたら Use JSDoc: @type に説明されていた。

Number 型があるとして、それを null 許容型にするなら {?Number}、許容しないなら {!Number} とする。試しにこれを jsdoc-to-assert に渡してみたら

/**
 * Output log.
 *
 * @param {String|?String} message Message text.
 * @param {String|!String} message2 Message text.
 */
function func(message, message2) {
  console.assert(typeof message === "string" || message == null || typeof message === "string", 'Invalid JSDoc: typeof message === "string" || (message == null || typeof message === "string")');
  console.assert(typeof message2 === "string" || message2 != null && typeof message2 === "string", 'Invalid JSDoc: typeof message2 === "string" || (message2 != null && typeof message2 === "string")');

  console.log(message);
}

というコードが生成された値をちゃんと null 判定しており、しかも許容と非許容も区別されている。判定の演算子を ===!== にしないのは null 判定においてを厳密に型チェックする意味がないからだろうか。

なお、null 許容だけだと型チェックが抜けるので、本来の型も列挙しておいたほうがよい。

あと ESDoc もこの記法に対応していた。null 許容と非許容を適切に判定し、元の型へのリンクを生成しつつ Attribute 欄に nullable: false/true を記述してくれる。

この情報を知れたのは収穫だった。

まとめ

JavaScritp 組み込み型に限定されるが、JSDoc さえキッチリ書いておけば、それだけで型チェックできるというのは便利ではなかろうか。独自の型を使用するような場所ではダック タイピングすればよく、その場合は assert ではなくユーザー向けコードでも明示的にインターフェースのチェックとエラー処理を実行したくなるだろう。

そういう意味でも今の私なら jsdoc-to-assert ぐらいで実用十分である。

今回の記事で作成したサンプル プロジェクトを以下に公開した。実際に動かして、assert がどのように出力されるか、コンパイルされたコードはどうなっているかを確認できる。

Electron を試す 8 – electron-prebuilt のパッケージ名変更と Browserify

2016年8月18日 0 開発 , ,

electron-packager の更新履歴をみていたら v7.5.0Add support for the new electron package name by zeke という PR に対応していた。内容を読むと electron-prebuilt のパッケージ名が electron に変更されたようだ。electron-prebuilt の README にも注記されている。

というわけで、このシリーズで作成したサンプルも名所変更に対応することにしたのだが Browserify 絡みで問題が起きたため、その内容と対策を記録しておく。

シリーズまとめ
Electron を試す

electron-prebuilt のパッケージ名変更

electron-prebuilt は Electron アプリの実行環境になる。従来、これをインストールするには

$ npm i -D electron-prebuilt

としていたのだが、v1.3.1 からパッケージ名が electron に変更されたので、以降は

$ npm i -D electron

とする。と、ここまでなら名前が短く分かりやすいので歓迎したいのだが…

Browserify のビルド問題と対策

Electron 本体に依存する機能は v1.0 から electron というパッケージ名で提供される。この辺の話は Electron を試す 7 – Electron v1.0 対応でも触れている。v1.0 より前は機能単位でパッケージ名を分けていたのだが、electron 配下へ統合された。

これを参照する場合、

const Electron = require( 'electron' );

とするか、ES2015 Modules であれば

import Electron from 'electron';

のようになるだろう。

通常はこれでよいのだが Browserify を利用して require/import を解決している場合は問題が起きる。electron-prebuilt のパッケージ名が electron に変更されたことで、electron に対する require/import が electron-prebuilt の実体を参照しようとしておかしくなるのだ。Browserify によるビルド結果は

module.exports = path.join(__dirname, fs.readFileSync(path.join(__dirname, 'path.txt'), 'utf-8'))

のようになる。そして electron-prebuilt からアプリを起動すると以下の実行時エラーが発生。

Error: ENOENT: no such file or directory, open '.../electron-starter/src/path.txt'
    at Error (native)
    at Object.fs.openSync (fs.js:640:18)
    at Object.module.(anonymous function) [as openSync] (ELECTRON_ASAR.js:167:20)
    at Object.fs.readFileSync (fs.js:508:33)
    at Object.fs.readFileSync (ELECTRON_ASAR.js:500:29)
    at Object.global.1.fs (.../electron-starter/src/main.js:5:42)
    at s (.../electron-starter/src/main.js:1:333)
    at .../electron-starter/src/main.js:1:384
    at Object.global.5.../common/Constants.js (.../electron-starter/src/main.js:231:17)
    at s (.../electron-starter/src/main.js:1:333)

この問題を回避する方法はふたつ。

  1. electron-prebuilt を旧名称で npm install する
  2. electron-prebuilt に対する require/import 参照を Browserify の対象外とする

方法 1 は electron-prebuilt 的に deprecated とされている。将来、旧名称が廃止される可能性もあって危険だ。よって正攻法の 2 を採用する。

substack/node-browserify の Usage を読むと --exclude オプションにパッケージ名を指定することで bundle ( 参照解決 ) の対象外となるようだ。

–exclude, -u Omit a file from the output bundle. Files can be globs.

というわけで npm-scripts で

{
  "scripts": {
    "build:js-main": "browserify -t [ babelify ] ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js"
  }
}

となっていたものを

{
  "scripts": {
    "build:js-main": "browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js"
  }
}

に修正してビルドしたところ、アプリが正常に起動 & 動作することを確認できた。

Renderer プロセスについて

Renderer プロセスについて。私がこちらで electron を参照する場合、

const Electron = window.require( 'electron' );

のように window 経由で require を利用しているため Browserify の対象外なのだが、念のためこちらをビルドするときも --exclude electron している。なぜ Main/Renderer で参照方法を分けているのかは、

  • Main
    • electron の他にも fs など dependency にないパッケージを参照する可能性が高い
    • そのため --im オプションで dependency に見つからない参照を無視している
    • 今回の問題は electron が見つかるようになってしまったことで発生した
  • Renderer
    • dependency に存在するパッケージのみで構成
    • Web ブラウザ用の JavaScript ビルドと同じ思想で参照解決
    • --im オプションを利用しないので electron を直に require できない
    • よって window.require 経由で参照して Browserify の介在を回避

という理由から。この辺の話は Electron を試す – 開発環境の構築でも触れたが、今回の問題にも関わっているので改めて書き出してみた。

ちなみに Renderer が利用する electron 由来の機能も ipRenderer に限定している。プロセス間通信は Web アプリにおける Client–Server Model を踏襲し、

  • Clinet ( Renderer ) が必要に応じて Server ( Main )Request ( ipcRenderer )
  • ServerRequest の結果を ClientResponce ( sender.send )
  • Server は必要に応じて ClientPush Notification ( ipcMain )

という感じで設計している。

akabekobeko/examples-electron には対応を反映済み。各プロジェクトの npm-scripts で --exclude electron を削除してみれば、今回の問題とおかしくなったビルド結果を確認できる。