puppeteer 13.3.2

puppeteer 13.3.2がリリースされました。

さっそくリリースノートを見ていきましょう。

Bug Fixes
・always use ENV executable path when present
・use require.resolve instead of __dirname

https://github.com/puppeteer/puppeteer/releases/tag/v13.3.2

今回はそこまで影響がある修正ではありませんでしたが、先日のCommon JS関連でもう1つだけ。

__dirname はCommon JSでのみ使える変数です。ES moduleではありません。同様に __filename も同じです。ES moduleで書く場合は使えないので気を付けましょう。

puppeteer 13.3.1

puppeteer 13.3.1がリリースされました。

さっそくリリースノートを見ていきましょう。

Bug Fixes
・puppeteer: revert: esm modules

https://github.com/puppeteer/puppeteer/releases/tag/v13.3.1

先日紹介した ES module に関する修正を巻き戻したとのことです。

ここでちょっとだけおさらいを。ES moduleではexportsで何をモジュールとして提供するかを明確に指定することができます(一応defaultsもあるのですが、今回は説明を割愛)。これは、モジュールで提供されている関数やクラスなどを後から上書きできないようする、いわばES moduleのセーフティ機構でもあるわけです。これが前回の修正では裏目に出てしまったようです。

Common JSではモジュールは結局のところただのオブジェクトです。そのためモジュールとして公開している意図はなかったとしても、オブジェクトからプロパティとして見えていたら使えてしまうわけです。この puppeteer の内部のプロパティを参照する使い方をしている NodeJS モジュールがあったのが問題だったわけです。 Common JS では問題なく見えていたのですが、 ES module 用に厳密なexportsを定義したところ、puppeteerチームは公開しているつもりがなかったプロパティがexportsされなくなってしまったのです。そして、このプロパティを参照してた NodeJS モジュールの方では値が取れなくなってしまったわけです。

JavaやC#、そしてTypeScriptであればprivate/publicなどで制御できたかもしれませんが、ゆるゆるなJavaScriptだと気付かないところでプロパティを直参照される・・・なんてことが起きやすいのです。そういった不慮の公開(?)を防ぐ上でも ES module としてexportsすることに効果はあると思います。

puppeteer 13.3.0

先日13.2.0が出たばかりなのですが、puppeteer 13.3.0がリリースされました。

さっそくリリースノートを見ていきましょう。

Features
・puppeteer: export esm modules in package.json

https://github.com/puppeteer/puppeteer/releases/tag/v13.3.0

今回の修正はesm modulesの対応でした。ところで Common JS と ES module の違いはご存知でしょうか?

Common JS

Common JS は従来より NodeJS のデフォルトとなっていたモジュールの管理方式です。 require 文を使ってモジュールを呼び出す書き方でよく見ると思います。実際、 puppeteer のサンプルも Common JS で書かれています。

Common JS では async/await を使うときに注意があります。グローバルスコープでは async/await を書くことができないのです。そのため、(async () => { ... })() のように async/await の処理を即時関数の中に閉じ込める必要があります。下にサンプルコードを示しておきます。13.2.0で追加されたiPhone 13のデバイス定義をさっそく使っています。

const puppeteer = require('puppeteer');

(async () => {
        const browser = await puppeteer.launch({
                headless: true
        });
        const page = await browser.newPage();
        await page.emulate(puppeteer.devices['iPhone 13']);
        await page.goto('https://www.magisystem.net/blog/', {
                waitUntil: 'networkidle0'
        })
        await page.screenshot({ path: 'example.png' });
        await browser.close();
})();

Common JSでは従来の .js 拡張子に加えて .cjs を使うことができます。これは .js だと Common JS か ES module かの区別が付き難いからです。明示的に Common JS と言いたいときは .cjs とするとよいでしょう。

ES module

一方で、 ES module はES2015 (ECMAScript 2015/ES6)で標準化されたJavaScriptの仕様です。 Common JS との最大の違いは、ES2015に準拠したブラウザでも使えるということです。つまり、ブラウザでもモジュールを使えるようにしたのがこの仕様です。 import 文と export 文を使ってモジュールを使用します。最近の npm モジュールもこの書き方が増えてきましたので、目にする機会もあると思います。

ES moduleの import の特徴は2つあります。

1つは require と異なり、 export した名前(クラス、定数、関数など)を後から上書きすることを防止しています。つまり、悪意のあるモジュールが別のモジュールの関数やクラスを上書きできないようにして、安全性を高めているわけです。

2つ目は import の処理が非同期であることです。 Common JS の require は同期処理です。そのため、 require しているモジュールが更にいくつものモジュールを読み込んでいたり、何か重たい初期化処理があると readFileSync() のように require の部分で待ちが発生します。 ES module の import は読み込み自体は非同期に行い、モジュールのクラスや関数を使うときにリゾルブして処理を始めるように動作します。よって、多くのモジュールを読み込む場合には速度メリットが得られるようになります。その反面、実行時まで解決されないので、トランスパイラや静的パーサーと相性が悪いようです。

さて、 ES module は import が非同期であるため、グローバルスコープで async/await をそのまま書くことができます。即時関数に閉じ込める必要がなくなるのは便利です。下にサンプルコードを示しておきます。

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
        headless: true
});
const page = await browser.newPage();
await page.emulate(puppeteer.devices['iPhone 13']);
await page.goto('https://www.magisystem.net/blog/', {
        waitUntil: 'networkidle0'
})
await page.screenshot({ path: 'example.png' });
await browser.close();

ES moduleでは従来の .js 拡張子に加えて .mjs を使うことができます。明示的に Common JS と区別したい場合は .mjs とするとよいでしょう。 ES Lint などのパーサーも .mjs にしないと ES module と認識しないのか、グローバルスコープの async/await をエラーにする場合があります。

Common JS or ES module?

Common JS で書くか ES module で書くか…これは宗教戦争になってしまうので明言は避けておきますが、指針は出しておきたいと思います。

まず、 NodeJS でしか実行しない場合は Common JS でいいと思います。普及している npm モジュールも Common JS ですし、 ES module に対応していないものもまだあります。ただ、 async/await を標準として使って書く場合は、上記の理由から ES module にした方が可読性が上がります。また、 async/await 系の処理を使う npm モジュールは、大抵の場合は import にも対応しています。

次にブラウザと NodeJS で共通する処理を書く場合です。この場合は ES module で書くことを始めた方がよいでしょう。従来のように全てをグローバルスコープに読み込む方法から、モジュールを使う方法に変えていくのがよいです。ブラウザに関して言えば、Chromeはもちろん、Firefox、Opera、Safariもサポートしているので、現在のブラウザ環境では問題なく使えると言えます。なお、ブラウザで使う場合は拡張子を .js のままにしておくのがよいです。というのはウェブサーバは .js しかJavaScriptと扱わないことが多いからです。

おまけ: sandboxについて

puppeteerのノウハウには --no-sandbox を指定するように書いている記事が多く見られます。しかし、これはセキュリティ上からはお薦めしません。セキュリティに気を使うGoogleがデフォルトで用意しているsandboxの仕組みをあえて無効にする必要はありません。

[kusanagi@kusanagi8 pp]$ node index.cjs
/home/kusanagi/work/pp/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:229
            reject(new Error([
                   ^

Error: Failed to launch the browser process!
[0210/100340.343786:FATAL:zygote_host_impl_linux.cc(117)] No usable sandbox! Update your kernel or see https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md for more information on developing with the SUID sandbox. If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox.
#0 0x55c3695e7a59 base::debug::CollectStackTrace()
#1 0x55c36954ee93 base::debug::StackTrace::StackTrace()
#2 0x55c369561e60 logging::LogMessage::~LogMessage()
#3 0x55c3676715ab content::ZygoteHostImpl::Init()
#4 0x55c3690ffc8a content::ContentMainRunnerImpl::Initialize()
#5 0x55c3690fd6d6 content::RunContentProcess()
#6 0x55c3690fe0c7 content::ContentMain()
#7 0x55c369158f1a headless::(anonymous namespace)::RunContentMain()
#8 0x55c369158c25 headless::HeadlessShellMain()
#9 0x55c365e6633b ChromeMain
#10 0x7fa6c7054555 __libc_start_main
#11 0x55c365e6616a _start
(以下省略)

もしもCentOSの環境で上記のようなエラーが出た場合は、以下のコマンドを実行してみてください。

[kusanagi@kusanagi8 pp]$ sudo sysctl -w user.max_user_namespaces=28633

usernsを有効にすることでsandboxが使えるようになります。

puppeteer 13.2.0

立て続けにpuppeteer 13.2.0がリリースされました。

さっそくリリースノートを見ていきましょう。

Features
・add more models to DeviceDescriptors
・chromium: roll to Chromium 99.0.4844.16
Bug Fixes
・make projectRoot optional in Puppeteer and launchers
・migrate more files to strict-mode TypeScript
・typos in documentation

https://github.com/puppeteer/puppeteer/releases/tag/v13.2.0

今回の追加機能はどちらも興味深いですね。

add more models to DeviceDescriptors

DeviceDescriptorsにAppleのハードウェアが一気に追加されました。これまではどちからというとAndroid系ばっかりだったので、ここにきて最新のiPhone 12やiPhone 13の定義が追加されるのはありがたいです。

chromium: roll to Chromium 99.0.4844.16

puppeteer 13.1.0でChromium 98に上がったばかりでしたが、さっそくChromium 99に上がりました。先日Chrome 97が正式リリースされた際に「そろそろChromeのバージョンが3ケタ(100)になるので準備してね!」という発表がありましたが、その100まであと1つにまで迫りましたね。

バグやセキュリティ対応は影響が少なめですが、重要な機能ですのでしっかりアップデートしておきましょう。

puppeteer 13.1.2 & 13.1.3

ちょっと間があいてしまいましたが、puppeteer 13.1.213.1.3 がリリースされました。

まずは 13.1.3 の方からです。

Bug Fixes

・issue with reading versions.js in doclint
・make more files work in strict-mode TypeScript
・page.pdf producing an invalid pdf

https://github.com/puppeteer/puppeteer/releases/tag/v13.1.3

PDFを生成する際に発生する可能性があったバグが修正されています。自分はそのバグを踏んだことがないので、ちょっと正確な条件は分かりませんでした。

一方で重要なのは 13.1.2 の方です。

Bug Fixes

package.json: update node-fetch package
・types in Browser.ts to be compatible with strict mode Typescript
・types in Connection.ts to be compatible with strict mode Typescript

https://github.com/puppeteer/puppeteer/releases/tag/v13.1.2

この1つ目 package.json が問題でした。実際に1つ前のバージョンである13.1.1をnpmでインストールしてみましょう。

$ npm install puppeteer@13.1.1 --save

changed 2 packages, and audited 58 packages in 12s

8 packages are looking for funding
  run `npm fund` for details

2 high severity vulnerabilities

To address all issues, run:
  npm audit fix --force

Run `npm audit` for details.

これは puppeteer 13.1.1 が依存している他のnpmパッケージに脆弱性があるものが含まれるというメッセージです。詳細も確認しましょう。

$ npm audit
# npm audit report

node-fetch  <2.6.7
Severity: high
node-fetch is vulnerable to Exposure of Sensitive Information to an Unauthorized Actor - https://github.com/advisories/GHSA-r683-j2x4-v87g
fix available via `npm audit fix`
node_modules/node-fetch
  puppeteer  10.0.0 - 13.1.1
  Depends on vulnerable versions of node-fetch
  node_modules/puppeteer

2 high severity vulnerabilities

To address all issues, run:
  npm audit fix

puppeteerで使っているnode-fetch 2.6.5に脆弱性があるということが示されました。puppeteer 13.1.1のpackage.jsonも確認してみましょう。

(省略)
  "dependencies": {
    "debug": "4.3.2",
    "devtools-protocol": "0.0.948846",
    "extract-zip": "2.0.1",
    "https-proxy-agent": "5.0.0",
    "node-fetch": "2.6.5",
    "pkg-dir": "4.2.0",
    "progress": "2.0.3",
    "proxy-from-env": "1.1.0",
    "rimraf": "3.0.2",
    "tar-fs": "2.1.1",
    "unbzip2-stream": "1.4.3",
    "ws": "8.2.3"
  },
(省略)

ここですね。ちなみに puppeteer 13.1.2 では以下のように直っています。

(省略)
  "dependencies": {
    "debug": "4.3.2",
    "devtools-protocol": "0.0.948846",
    "extract-zip": "2.0.1",
    "https-proxy-agent": "5.0.0",
    "node-fetch": "2.6.7",
    "pkg-dir": "4.2.0",
    "progress": "2.0.3",
    "proxy-from-env": "1.1.0",
    "rimraf": "3.0.2",
    "tar-fs": "2.1.1",
    "unbzip2-stream": "1.4.3",
    "ws": "8.2.3"
  },
(省略)

NodeJSのモジュールは依存するモジュールを npm install --save で手軽にインストールできます。一方でRedHat/CentOSのRPMやUbuntuのdebのように自動では更新されません。上記のpuppeteerのように依存するNodeJSモジュールのバージョンを固定してしまっていると、そのバージョンに脆弱性があったとしても、そのまま使うことになります。

一方で npm outdated などで更新できるようにする方法もあります。その場合は package.json のバージョンを固定にしないで「このバージョン以上」とすることです。

    "node-fetch": "^2.6.7",

こうすることで仮にnode-fetchの2.6.8がリリースされたら、自動的にそちらをインストールするようにできるのです。

puppeteer 13.1.1 ではこの「^」がなかったことで古い(2.6.5)node-fetchを指定したままとなりました。最終的には新しい(2.6.7)node-fetchを指定したpuppeteer 13.1.2がリリースされましたが、しばらくの間はpuppeteerのビルドが失敗するという状況が続いてしまいました。かくいう自分も、これが原因でしばらくの間CI/CDがエラーになってしまい、puppeteerを使ったテストが行えない状態にありました。もしも上記のように「^」を含めて、自動的に新しいnode-fetchを使うようになっていたらpuppeteerの更新を待つ必要はなかったですし、そもそもpuppeteerも新しいリリースを出す必要もなかったのではないかと思いました。

もちろん、バージョンが上がることで動作が変わったり、互換性がないこともあります。そうした確認をしていないのに、新しいバージョンを使うように指定することに躊躇することもあるでしょう。ですが、マイクロバージョンであれば(一般的には)大きな仕様の変更はないと考えられます。利用者の利便性を考えたら、バージョン固定ではなく、最新版を使うようにpackage.jsonを書く方がよいのでないか?と思ったケースでした。