Reactのプロキシを通すとSSE (Server-sent events)のレスポンスを受け取れない

React、というかreact-scriptsはプロキシ機能を備えています。

このプロキシ、基本的にはウェブAPIのために用います。React自体はlocalhost:3000で実行し、ウェブAPIはlocalhost:8080で実行する場合、localhost:3000にウェブAPIのリクエストを投げるとlocalhost:8080にプロキシする、という具合ですね[^1]。

使用方法も簡単で、 `package.json` に下記のようなフィールドを付け加えるだけで動作します。

```json
{
  // ...
  "proxy": "http://localhost:8080",
  // ...
}
```

ただこのプロキシを通してSSE (Server-sent events)なウェブAPIにリクエストを投げると、レスポンスのヘッダーなどは受け取れるものの、本文が受け取れません。悲しいね……。

という事実を述べて終わりたいわけじゃなく、なぜそうなるのか、どうすれば解決できるのかまとめてみました。

## 手っ取り早く解決したい方へ

「理由はどうでもいいから一刻も早く解決したい！」という方はここ読むだけで大丈夫です。

Reactアプリケーションのソースコードが置いてあるルートに以下の内容を `setupProxy.js` というファイル名で保存してください。ちなみに/apiへのリクエストをlocalhost:8080にプロキシする、という内容ですので、その辺は適宜変更してください。

```javascript
const proxy = require('http-proxy-middleware');

module.exports = (app) => {
  app.use('/api', proxy({
    target: 'http://localhost:8080',
    onProxyReq: (proxyReq, req) => {
      if (req.headers['accept'] === 'text/event-stream') {
        req.headers['accept-encoding'] = 'identity';
      }
    }
  }));
};
```

このファイルを置いた後、react-scripts restartを行えば解決すると思います。

## 原因

react-scriptsは内部でwebpack-dev-serverを利用しています。webpack-dev-serverはさらにExpress.jsを内包しており、そのミドルウェアで様々な機能を実現しています。プロキシもそのひとつです[^2]。

そして、デフォルトでレスポンスの圧縮も行っています[^3]。圧縮もExpress.jsのミドルウェアで実現されており、[compression](https://github.com/expressjs/compression)がその実体です。

で、原因はずばりこの圧縮にあります。この件はすでに[Option to disable the compression for the webpack dev server · Issue #7847 · facebook/create-react-app](https://github.com/facebook/create-react-app/issues/7847)などで言及されています。

## 対処

さきほどのIssueにもコメントとして残されていますが、SSEなウェブAPIのレスポンスヘッダーに `Cache-Control: no-transform` を付与すればこの問題は解決します。

なぜ解決するかは[compressionミドルウェアのソースコード](https://github.com/expressjs/compression/blob/master/index.js)を眺めると簡単に分かります。

compressionミドルウェアは以下のいずれかに該当するときに**圧縮を行いません**。

- [filter関数がfalseを返したとき](https://github.com/expressjs/compression/blob/master/index.js#L141-L145)
  - [filterオプションを与えたときはそれをfilter関数として利用。そうでないならshouldCompress関数を利用](https://github.com/expressjs/compression/blob/master/index.js#L52)
  - [shouldCompress関数は、Content-Typeが不明だったり、圧縮可能なContent-Typeでないときfalseを返す](https://github.com/expressjs/compression/blob/master/index.js#L254-L263)[^4]
- [shouldTransform関数がfalseを返したとき](https://github.com/expressjs/compression/blob/master/index.js#L147-L151)
  - [shouldTransform関数はCache-Controlヘッダーにno-transformが含まれているときfalseを返す](https://github.com/expressjs/compression/blob/master/index.js#L270-L277)
- [Content-Lengthが閾値（デフォルト: 1024）未満のとき](https://github.com/expressjs/compression/blob/master/index.js#L156-L160)
- [既に何かしらの圧縮が施されていたとき](https://github.com/expressjs/compression/blob/master/index.js#L162-L168)
- [リクエストメソッドがHEADのとき](https://github.com/expressjs/compression/blob/master/index.js#L170-L174)
- [Accept-Encodingヘッダーにidentityが含まれているとき（実際は重みに依る）](https://github.com/expressjs/compression/blob/master/index.js#L176-L189)

となっているので、 `Cache-Control: no-transform` とすると、2番目の条件に該当して圧縮が行われない、というわけです。

## もう一歩踏み込んだ対処

`Cache-Contro: no-transform` が妥当なケースならそれで良いはずです。

ただ、 `Cache-Control: no-transform` は[Cache-Control - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control#Other)でも触れられている通り、プロキシにおけるあらゆる変換の禁止を宣言するものです。それにより圧縮も行われなくなるのですが、圧縮は許容しないが、他の変換は許容するという場合には不適切です。

まーーーーーーーいろいろ考慮することはあると思うのですが、今回に限れば圧縮が悪さしているので、圧縮にのみアプローチできるのが最も影響範囲が小さいと思います。そうなると `Cache-Control: no-transform` はやや影響範囲が大きいかな……、と思う。ちょっと強引な気もするけど。SSEであれば実際はあらゆる変換を禁止するのが妥当な気もするけど。

とりあえず圧縮にのみアプローチしたい、となった場合、やはり着目すべきは[Accept-Encodingヘッダー](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Accept-Encoding)でしょう。実際compressionミドルウェアもAccept-Encodingヘッダーにidentityが含まれていれば圧縮を行わないので、対処自体は妥当だと思います。

というわけでAccept-Encodingヘッダーを付けよう！と思ったけど、SSEのクライアントとして使うであろう[EventSource](https://developer.mozilla.org/ja/docs/Web/API/EventSource)はリクエストヘッダーを指定できない！ていうかそもそもAccept-Encodingヘッダーは[プログラムによる指定を禁止されている](https://developer.mozilla.org/ja/docs/Glossary/Forbidden_header_name)！

となるので、先にも挙げたこのコードになるのです。

```javascript
const proxy = require('http-proxy-middleware');

module.exports = (app) => {
  app.use('/api', proxy({
    target: 'http://localhost:8080',
    onProxyReq: (proxyReq, req) => {
      if (req.headers['accept'] === 'text/event-stream') {
        req.headers['accept-encoding'] = 'identity';
      }
    }
  }));
};
```

react-scriptsは[setupProxy.jsを読み込むようになっており、そこで詳細なプロキシの設定を行えます](https://create-react-app.dev/docs/proxying-api-requests-in-development/#configuring-the-proxy-manually)。

そのとき、http-proxy-middleware（実際は内包しているhttp-proxy）にて[プロキシ時のイベントを捉えることが出来ます](https://github.com/http-party/node-http-proxy#listening-for-proxy-events)。そこで、EventSourceからのリクエスト、つまりAccept: text/event-streamのときに限りAccept-Encoding: identityとしてあげます。

するとcompressionミドルウェアはそれを考慮し、圧縮を行いません。

すごくめでたしな感じしません？な？なーーー！？とりあえず私はこうしています。

## また別の対処

CORSを使え！

[^1]: もちろんCORSを利用して直接呼び出すのもあり:ant:
[^2]: https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/scripts/start.js#L123-L135
[^3]: https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/config/webpackDevServer.config.js#L45-L46
[^4]: 圧縮可能かは[compressible](https://github.com/jshttp/compressible)に委譲している