◆ OPTION メソッドの preflight request が送られるようになった
◆ Content-Type: application/json は preflight request を送る条件になる

POST データを見て CORS 許可

別サイトから fetch される前提のページで CORS 許可するようにしていました
ただ 許可するのは特定のリクエストだけで良くて 全許可にしてしまうのもなぁ という気持ちがあったので POST データに特定のキーがある場合のみ Access-Control-Allow-Origin をレスポンスに含めるようにしてました
簡単な認証みたいなものです

サーバのコードはこういうのです

const readStream = stream => {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0)
stream.on("data", ch => (buf = Buffer.concat([buf, ch])))
stream.on("end", () => resolve(buf.toString()))
stream.on("error", err => reject(err))
})
}

require("http").createServer(async (req, res) => {
const body_promise = readStream(req, "utf-8")

console.log(req.method, req.url)

if(req.method === "POST") {
const body = await body_promise
try {
const data = JSON.parse(body)
if(data.key !== "PASSWORD") {
throw new Error("Invalid key")
}
res.setHeader("Access-Control-Allow-Origin", "*")
console.log("allow cors")
} catch(err) {
console.log("deny cors", err)
}
console.log("req body: ", body)
}
res.end(`${req.method} ${req.url}`)
}).listen(8200)

このサーバにブラウザからこういう感じでアクセスします

let res = await fetch(
"http://localhost:8200/",
{
method: "POST",
body: JSON.stringify({ key: "PASSWORD", text: "aa"}),
}
)
console.log(res)
console.log(await res.text())

key が違えば CORS が許可されていないのでエラーになります

Content-Type 追加したら

サーバ側で特にリクエストの Content-Type を見ていないので特に指定なし (text/plain のはず) で送ってました
それで問題はなかったのですが なんとなく送信内容は JSON なんだし ちゃんとわかりやすくリクエストヘッダーに application/json 指定しておこうと思って追加しました

let res = await fetch(
"http://localhost:8200/",
{
method: "POST",
body: JSON.stringify({ key: "PASSWORD", text: "aa"}),
headers: {
"Content-Type": "application/json",
},
}
)
console.log(res)
console.log(await res.text())

CORS エラーになりました

エラーをよく見るといつもとちょっと違います

application/json を設定せずに key を送らなかった場合などに起きるいつもよく見るエラーはこれです

Access to fetch at 'http://localhost:8200/' from origin 'http://localhost:8100/' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

今回のはこれです

Access to fetch at 'http://localhost:8200/' from origin 'http://localhost:8100/' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

「Response to preflight request doesn't pass access control check:」

という一文が増えてます
preflight request の問題のようです

preflight request というと CORS 確認のために送られる OPTION メソッドのリクエスト だったと思います
ただ OPTION メソッドなんて実装したこともないのですが それで困ったこともなかったです
特に意識することもなく 知らない間に仕様が変わってなくなったのかなーくらいの扱いでした

今回のはその preflight request が発生してるようです
条件がよくわかってないので調べてみることにしました

CORS preflight request

詳しくは MDN を見るのが早いです
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#Examples_of_access_control_scenarios

色々書かれてますが preflight request を送るかどうかは Simple requests とみなされるかどうかで決まるようです
シンプルなら送る必要ないけど シンプルじゃないなら送るということです
Simple requests という言葉自体は MDN の解説に使ってるだけで仕様で使われてる言葉ではないようです

シンプルに当てはまるには次の全てを満たす必要があります

  • リクエストメソッドが HEAD, GET, POST のどれか
  • リクエストヘッダが許可されてるものだけ
  • リクエストヘッダの Content-Type は application/x-www-form-urlencoded, multipart/form-data, text/plain のどれか
  • リクエストに使うどの XMLHttpRequestUpload オブジェクトにもイベントリスナがついていない
  • リクエストに ReadableStream を使っていない

preflight request を送らない条件

DELETE や PUTS みたいなマイナーなメソッドを使うとそれだけで preflight request が起きます

リクエストヘッダに設定できる項目も決まっていて UA (ブラウザ) が自動で設定するものと CORS-safelisted request-header として定義されてるヘッダのみです
CORS-safelisted request-header のリストはこれです

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (but note the additional requirements below)
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width

Content-Type だけ注意書きがあって 条件一覧の次の項目にもあるように これだけは特別です
ヘッダに設定される値によって許可されるかどうかが決まります
指定なしの text/plain かフォームをつかって送信するときに使われる application/x-www-form-urlencoded と multipart/form-data のみ許可されています
application/json は許可されていないので Simple requests の条件から外れてしまうわけです

XMLHttpRequestUpload は fetch ではなく XHR を使う場合の条件のようです
XHR はそれほど使ったこと無いので 使ったことない機能ですが xhr インスタンスの upload プロパティでアクセスできるオブジェクトが XMLHttpRequestUpload です
見た感じアップロード状況や cancel, timeout などのイベントを受け取れるみたいです
わざわざこれらを設定するのは 大きめのファイルのアップロードなど シンプルじゃない通信だろうという判断のようです

ところで日本語版の MDN では XMLHttpRequestUpload の説明で「これらは正しく」とよくわからないことを言ってますが 英語版に「正しく」なんて言葉はなくて普通に upload プロパティでアクセスできるものですと補足してるだけです

最後の ReadableStream ですが レスポンスのボディを stream として使うことはあってもリクエストのボディに設定するのはほとんどないように思います
ローカルファイルのアップロードも File 型を FormData に設定することになりますし
あるとすれば ダウンロードしたデータをそのまま別のところにアップロードしたいというときでしょうか
ブラウザでやったことはないですが レスポンスの ReadableStream をそのままアップロード用のリクエストに設定すればできると思います
Node.js でダウンロードしたものをそのままアップロードするときにそんなことしてました
わざわざそんなことするならまずシンプルではないですね

まとめ

リクエストの Content-Type を application/json にしたら preflight request が送られます
対応が面倒なので外部公開するわけでもないなら JSON 送ってても Content-Type は text/plain にしておけばいいかなと思いました