Node.js
jsdom
React
axios

React+Node.js+axios+jsdomでTDDしようとしたらCORS問題にぶつかった話

Access-Control-Allow-Origin とかでエラーになるあれです。
React+Node.js+axios+jsdomでRequest投げた場合に発生しました。

ぱっと見の原因は、jsdomの作成時にURLが設定されていない場合、originに文字列で "null" が入ってしまい、jsdomのオリジンチェックで、文字列じゃない null(null !== "null") という感じで比較してエラー判定しているからだと思いました。

ここの関数で比較しています

そして、文字列の "null" が入るのはバグじゃないか?と調べてみたのですが、npmモジュールのwhatwg-urlのこの辺で、文字列nullが正しい的な記述がある。

古川さんのissueでも正しいように見えると答えているので、whatwgの仕様が気になったのですが、ちょうどNode.jsが実装したみたいな記事があったため試してみた。
(原典に当たれとか言われそうですが、今回やりたいのはCORS問題の解決なのでそこは捨てました。)

確認したいのは、originが存在しない場合に文字列nullが返ってくるのかどうか?です。ローカルのファイルアクセス用URLを使えばoriginが設定されていない状況になるので、それで試しました。

node -e 'const URL = require("url").URL; console.log(new URL("file://aaa.log"))'
URL {
  href: 'file://aaa.log/',
  origin: 'null',
  protocol: 'file:',
  username: '',
  password: '',
  host: 'aaa.log',
  hostname: 'aaa.log',
  port: '',
  pathname: '/',
  search: '',
  searchParams: URLSearchParams {},
  hash: '' }

結果、文字列"null"が帰ってくるという。(意外)

そもそもaxiosでエラーを投げるのは、CORS違反だからで、それ自体は正しい挙動だと思い直しました。

そして、originが設定されていないという状況がイレギュラーなんじゃないか?(Chromeの別タブ状態でXHRリクエスト投げた場合でも、originはgoogleのやつが入っていたりする。)

ということで、jsdomの生成時にURLを設定して試してみました。

// setup用のjs

require('babel-register')()

var JSDOM = require('jsdom').JSDOM
var dom = new JSDOM(`...`, {
  url: `localhost:3000` // ←ここ!!
})

global.window =  dom.window
global.document = dom.window.document

var exposedProperties = ['window', 'navigator', 'document']
Object.keys(document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    exposedProperties.push(property)
    global[property] = document.defaultView[property]
  }
})

global.navigator = {
  userAgent: 'node.js'
}

結果おk。

これで localhost:8080で立ち上がっているWebサーバにリクエストを投げてもエラーは発生しなかったです。ちなみにlocalhost指定だと駄目で、localhost:3000とかポートまで指定するとうまくいくという謎現象もありました。。。

また、global.window.document.origin の値が使われていたので、そいつを上書きすれば解決か?とか思ったのですが、set出来ない書き方をしていたため、変更できずでした。(個人的に分かりづらいので、この書き方は嫌いです。)

// set出来なくする書き方。。
// react-tdd/node_modules/jsdom/lib/jsdom/living/generated/Document.js#L798

Object.defineProperty(Document.prototype, "origin", {
  get() {
    if (!this || !module.exports.is(this)) {
      throw new TypeError("Illegal invocation");
    }

    return this[impl]["origin"];
  },

  enumerable: true,
  configurable: true
});

npm iしたあとに作成されるgeneratedフォルダ配下なので、npm iしたあとの中身で見ないと見れないかもです(jsdom@11.4.0

githubのリンクを貼れないからそれもやだなぁとか、結構調べるのに骨が折れたので共有でした。