Fetch じゃなくて navigator.sendBeacon を使ってみる・NestJS で受け取る

アクセス解析などの用途で、API サーバに POST リクエストを送りたい。当初は以下のように Fetch を書いていた。

fetch(url, {
  mode: 'cors',  // 別のサブドメインへの通信なので CORS を指定
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data)
}).catch(() => { /* エラーは無視 */ });

CORS のための HTTP ヘッダー設定などはすでに実施済みなので、とりあえずコレで JSON データを NestJS 製の API サーバに送って処理できていた。

しかし、最近色々調べていたら、この書き方ではページ離脱時・ブラウザタブを閉じた時などにリクエストが破棄される場合があると知った。

そこで今回は、Fetch API の keepalive オプション、そして navigator.sendBeacon() という API について調べたことをまとめていく。

目次

Fetch API の keepalive オプション

fetch() に keepalive: true というオプションを指定すると、リクエストの最中にページを離脱したりタブを閉じたりしても、通信が切断されなくなるそうだ。

fetch(url, {
  mode: 'cors',
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
  keepalive: true  // ← コレを追加した
}).catch(() => { /* エラーは無視 */ });

普段のリクエスト時は挙動に何も変化はなく、const response = await fetch(); と書けばレスポンスデータも普通に受け取れる。ページ離脱とかをした時のリクエストが破棄されにくくなるだけだ。

ただし条件が一つあって、keepalive: true を指定すると、リクエストボディの合計が 64KiB (65536 バイト) を超えた時にリクエストが失敗する仕様になっている。まぁ 64KiB なら日本語でも UTF-8 で2万文字分くらいの換算なので、アクセス解析のためにちょっとした JSON データを送るような用途なら十分余裕があると思う。

fetchLater() という API もある

ページを離脱するタイミングにリクエストをしたい、というような場合により向いている API があって、fetch() の書き味のまま使える fetchLater() という API もあるそうだ。

引数の仕様などは fetch() と同様だが、「その場でリクエストを開始し、レスポンスを受け取れる」モノではなく、「ブラウザ内部の送信キューにリクエスト情報を追加する」だけの関数となる。つまり実際にリクエストが行われるのは Promise の解決よりも後になり、レスポンスデータも受け取れない「遅延送信」専用のモノとなる。

Beacon API

歴史的にはこれらよりも以前から策定されていたモノとして、Beacon API というモノがある。今回の本題、navigator.sendBeacon() メソッドである。この API の特徴や制限は以下のとおり。

書き方はこんな感じ。

// 何か送りたいデータのイメージ
const body = {
  url: location.href
};

// 指定の URL に `Content-Type: text/plain` で POST 送信する
navigator.sendBeacon(url, JSON.stringify(body));

// このように書けば `Content-Type: application/json` で POST 送信できる
navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: 'application/json' }));

最初は application/json を指定して送ればいいやー、と思っていたのだが、sendBeacon() を使う「旨味」はあえての text/plain にあると分かったので、そのことについてもまとめておく。

CORS と歴史的経緯

なんで Content-Type: text/plain で送るのがデフォルトなのか、Content-Type: application/json で送るのはマズイのかどうか?という話には、CORS 制限が関係してくる。

こうした制限はセキュリティ面の懸念から考案されたもので、CORS の策定は2004年頃から始まり、正式承認されたのは2014年頃。

策定が始まった2004年当時、一般的なサイトでは「メソッドは GET か POST のどっちかだけ」「XMLHttpRequest (XHR) のブラウザ対応状況もまばら」「HTTPS 対応してなくても普通」というような時代。ブラウザを使う多くの一般利用者にとってのセキュリティリスクを考慮して、後から策定されたワケだ。

「GET で何か取得するだけなら情報奪取のリスクも低く安全でしょう」「プレーンテキストやフォームデータの POST は既にもう広く浸透しているから、後からコレを規制しちゃうと困るサイトが出てくるでしょう」といった、いわば後方互換性を考慮して、これらを「シンプルリクエスト」と定義し、シンプルリクエストならオリジンが違っても最初から制限しないよ、というルールになっている。

JSON という形式は2001年に考案されたモノだが、RFC が初公開されたのは2006年、ECMA 国際標準になったのは2013年ということで、CORS の策定タイミングからすると「まだまだ普及率の低い新しい形式」であり、「だったら CORS であえて制限する対象にしておき、今後厳しくチェックするモノとして扱いましょう」という流れになったようだ。だから application/json の POST リクエスト時はプリフライトリクエストが飛ぶのである。

プリフライト中に破棄される可能性を避けるための text/plain

プリフライトは前述のとおり、2回の往復が発生している。つまり KeepAlive Fetch や Beacon API の実行時に、オリジンが異なることでプリフライトが発生すると、プリフライト中にブラウザのプロセスが完全に終了してしまい、POST 送信がされない可能性があるのだ。

その可能性を考えると、プリフライト分の往復を回避して、一発目から POST データを送りたい。そうするにはどうしたらいいか?リクエストの方式を「シンプルリクエスト」の条件に合うようにすればいいのだ。つまり application/json では送らず、text/plain で送ればいいじゃん、ということだ。

Beacon API が text/plain でのリクエストをデフォルトの挙動にしているのは、プリフライトをスキップできる「シンプルリクエスト」の中でも最も原始的な形式であり、リクエスト送信の成功率を高める狙いがあったと思われる。

ちなみに、Fetch API でも Beacon API でも、Content-Type を記述しなかった場合は、text/plain・application/x-www-form-urlencoded・multipart/form-data の3つのいずれかが自動指定されるようになっている。

// `Content-Type` のヘッダ指定がない場合 : `text/plain` になる
fetch(url, { method: 'POST', body: 'Text' });

// `JSON.stringify()` した結果は結局文字列なので、`text/plain` になる
fetch(url, { method: 'POST', body: JSON.stringify(data) });

// URLSearchParams を渡すと `application/x-www-form-urlencoded` になる
const params = new URLSearchParams('?name=Example&age=20');
fetch(url, { method: 'POST', body: params });

// FormData を渡すと `multipart/form-data` になる
const formData = new FormData();
formData.append('name', 'Example');
formData.append('age', 20);  // FormData は Boolean や Number を String に自動変換する点に留意
fetch(url, { method: 'POST', body: formData });

// JSON 形式で送りたい場合は明示的な指定が必要 (そしてプリフライトリクエストが発生する)
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
  body: JSON.stringify(data)
});
// `Content-Type` のヘッダ指定がない場合 : `text/plain` になる
navigator.sendBeacon(url, 'Text');

// `JSON.stringify()` した結果は結局文字列なので、`text/plain` になる
navigator.sendBeacon(url, JSON.stringify(data));

// URLSearchParams を渡すと `application/x-www-form-urlencoded` になる
const params = new URLSearchParams('?name=Example&age=20');
navigator.sendBeacon(url, params);

// FormData を渡すと `multipart/form-data` になる
const formData = new FormData();
formData.append('name', 'Example');
formData.append('age', 20);
navigator.sendBeacon(url, formData);

// JSON 形式で送りたい場合は明示的な指定が必要 (そしてプリフライトリクエストが発生する)
navigator.sendBeacon(url, new Blob([JSON.stringify(data)], { type: 'application/json' }));

名前がややこしいのだが、application/x-www-form-urlencoded のことを単に「フォームデータ形式」と呼ぶことがあるが、使用するのは URLSearchParams。FormData (「フォームデータ」) を使うと multipart/form-data になって、コレは「マルチパート形式」と呼ぶことが多い。単に「フォームデータ形式で~」と言った時にどちらを指しているのかは確認が必要だが、どちらにせよ古くからある形式だから「シンプルリクエスト」として許可されているワケだ。

sendBeacon() の場合は Content-Type ヘッダを直接指定する引数はないので、JSON にしたい場合は Blob の type で MIME タイプを指定することで、それが Content-Type ヘッダとして使用される仕組みになっている。繰り返しになるが application/json を指定してしまうと、CORS プリフライトリクエストが発生するので、タイミングによっては実際の POST リクエストが破棄されてしまうリスクがある。

KeepAlive Fetch と sendBeacon() は内部的に同様の実装で動いているらしいので、JavaScript コードとしての書き方に若干の違いはあれど、HTTP リクエストとしての扱われ方はよく似ているというかほとんど同じだ。

ということで、text/plain で送りましょう ← ココまでがクライアントの話

CORS 制限を回避してプリフライトをスキップし、POST 送信の成功率を上げるためにも、せっかく sendBeacon() を使うなら、text/plain のまま送っちゃいましょう。

const isQueued = navigator.sendBeacon(url, JSON.stringify(data));

sendBeacon() は一見、同期メソッドのように見える (await は要らない)。しかしリクエスト送信はちゃんとバックグラウンドで行われている。戻り値は「送信キューに追加できたか否か」を示す Boolean が返される。戻り値を確認する必要がなければ、変数に入れなくて良い。

最近のモダンブラウザなら普通に sendBeacon() に対応しているが、少し古い Safari などでは navigator.sendBeacon が存在しない場合がある。そうした場合のためのフォールバックとして、依然として KeepAlive Fetch を書くのもアリだ。

if(navigator.sendBeacon != null) {
  navigator.sendBeacon(url, JSON.stringify(data));
}
else {
  // Beacon API が使えなければ KeepAlive Fetch で代用する
  fetch(url, {
    method: 'POST',
    keepalive: true,
    body: JSON.stringify(data)
  }).catch(() => { /* エラーを握りつぶしておく */ });
}

Fetch は非同期関数なので、await を書かずに、戻り値を受け取らないようにすれば、sendBeacon() とほぼほぼ同等の動きをするモノになる。ただ、レスポンスエラー時に Uncaught In Promise エラーが裏で発生しないように、.catch() チェーンでエラーを握りつぶしておくと良い。

Beacon API と比べて、KeepAlive Fetch を含めた Fetch API は、レスポンスデータを受け取ろうと思えば受け取れる、というところが大きな違いだ。

前述のとおり、Beacon API も KeepAlive Fetch も、いずれもリクエストボディのデータ長は最大 64KiB という制限がある。もしもデータ長が大きくなる可能性がある場合は、KeepAlive を指定しない Fetch も用意しておくなどすれば、「なんとしてでもデータを送りつけるんだ」という意図のコードにはできる。もちろん、KeepAlive を指定しない Fetch はページ離脱時の通信が破棄されうるワケだが。

// 送りたいデータ
const payload = JSON.stringify(data);

// フォールバック用の通常 Fetch を呼ぶ関数
const normalFetch = () => {
  fetch(url, {
    method: 'POST',
    body: payload
  }).catch(() => { });
};

// 文字列のバイト数を正確に計測するために Blob を使う
const blob = new Blob([payload], { type: 'application/json' });
// 64KiB を超える場合は最初から普通の Fetch にして終了する
if(blob.size > 65536) return normalFetch();

// Beacon API が成功したか否かを控える
let isBeaconSucceeded = false;
// Beacon API が使えそうなら使う
if(navigator.sendBeacon != null) {
  try {
    isBeaconSucceeded = navigator.sendBeacon(url, payload);  // データ長が 64KiB 超・ブラウザキューが満杯だった場合は `false` が返る
  } catch {
    isBeaconSucceeded = false;  // 混在コンテンツ・CSP 違反などの場合は例外としてスローされる
  }
}

// Beacon API が使えなかったか、Beacon API が失敗した場合は KeepAlive Fetch にフォールバックする
if(!isBeaconSucceeded) {
  fetch(url, {
    method: 'POST',
    keepalive: true,  // KeepAlive
    body: payload
  ).catch(_error => {
    // KeepAlive 指定時も 64KiB 超で例外が発生しうる・その場合は通常 Fetch を試みる
    normalFetch();
  });
}

いずれも Content-Type 未指定なので、text/plain で送られるが、お好みで通常 Fetch は application/json を指定したりしても良い。…まぁ、ココまでやることはあんまなさそうだが…。w

サーバサイド (NestJS) で JSON でもプレーンテキストでも受け取れるようにする

だいぶ長くなったが、ココまではクライアントサイドで CORS を考慮した上で、sendBeacon() を使って送信するところだ。

自分はサーバサイドに NestJS を使っていたのだが、この NestJS、デフォルトでは text/plain で送られてきたデータを上手く取得できないのだ。

元のコントローラはこんな感じ。

// NestJS のコントローラ
@Controller('api')
export class MyApiController {
  // POST `/api/example` エンドポイントの定義
  @Post('example')
  public async example(@Body() myData: MyData, @Res() response: Response): Promise<Response> {
    // 引数に指定した `myData` は、NestJS が既に `JSON.parse()` 相当の処理をしたオブジェクトになっている
    
    return response.status(HttpStatus.OK).end();
  }
}

@Body() デコレータを使ってリクエストボディを取得しているのだが、この時、NestJS はリクエストヘッダの Content-Type を見て、リクエストボディに対して適切なパーサを通している。

つまり今回は、Content-Type として application/json を指定すれば、KeepAlive Fetch でも sendBeacon() でもうまく受信できていたが、text/plain で送ると途端に空オブジェクト {} 扱いになってしまう、という状態に遭遇した。

これに対する対処法はちゃんとあって、NestJS がベースとしている Express の text() ミドルウェアを噛ませるというモノだ。

実装方法もいくつかの方法があって、main.ts に書く方法、@Module デコレータが付いた class Module に書く方法、そして今回紹介するようにメソッドに限って text/plain を解釈できるように書く方法がある。

// Express から `text` 関数 (ミドルウェア) をインポートする
import { text, type Request, type Response } from 'express';

@Controller('api')
export class MyApiController {
  // 先ほどの例と比べて、`@Req()` を追加で引数に入れてある
  @Post('example')
  public async example(@Req() request: Request, @Body() myData: MyData, @Res() response: Response): Promise<Response> {
    // `navigator.sendBeacon()` で送信した場合は `text/plain` で届くので、その場合は生のリクエストデータを取得して JSON に変換する
    if(request.is('text/plain')) {
      try {
        await new Promise<void>((resolve, reject) => { text({ type: 'text/plain' })(request, response, error => error ? reject(error) : resolve()); });
      }
      catch(error) {
        const status = (error as { status?: number }).status === HttpStatus.PAYLOAD_TOO_LARGE ? HttpStatus.PAYLOAD_TOO_LARGE : HttpStatus.BAD_REQUEST;
        return response.status(status).json({ error: 'Failed To Read Body' });
      }
      
      // この時、引数 `myData` は空オブジェクトの状態なので、ココでパースして再代入する
      try {
        myData = JSON.parse(request.body);
      }
      catch {
        return response.status(HttpStatus.BAD_REQUEST).json({ error: 'Invalid JSON Body' });
      }
    }
    
    // `application/json` で送られてきた場合は上述の `if` 文を通らず、最初から引数 `myData` がパース済みの JSON になっている
    // `text/plain` で送られてきた場合も、`if` 文の中で自分で `JSON.parse()` した結果を `myData` に代入し直しているので
    // どちらの場合でも以降は引数 `myData` を利用すれば良い
    
    return response.status(HttpStatus.OK).end();
  }
}

こんな感じ。以前までの application/json のままリクエストした場合は if 文を通らないので何も変更がない。

text/plain で送られてきた場合は、if 文の中で Express の text ミドルウェアを通すことでリクエスト文字列を取得できるようにしたので、JSON.parse() して引き継いでやれば、後続処理のコードは変更しなくて良い。

main.ts や @Module() で処理する場合のコードは省略するが、やることは同じで text ミドルウェアを噛ませるだけ。自分は、この API エンドポイントだけ例外的に text/plain を送る場合がある構成だったので、他の API エンドポイントに影響を与えないようにするためこの実装に落ち着いた。

以上

長くなってしまったが、以上で navigator.sendBeacon() でも KeepAlive Fetch でも、application/json 指定の通常 Fetch でも、とにかく POST リクエストを送って、NestJS サーバで同じ形に整えて受け取れるようにできた。

CORS とプリフライトリクエストの仕様についても、ふんわりとしか分かっていなかったところや歴史的経緯なんかを学び直せたので、良い勉強になった。