created_at
updated_at
tags
toc
headings

Cookie Store API による document.cookie の改善

Intro

JS から Cookie を操作する document.cookie の改善を目的とした Cookie Store API についてまとめる。

document.cookie

document.cookie は、ブラウザの API における代表的な技術的負債の一つと言える。

基本的な使い方は以下だ。

document.cookie = "a=b"
console.log(document.cookie) // a=b

まず、この API の問題を振り返る。

同期 API

最も深刻なのは、 I/O を伴いながら、同期 API として定義されているところだ。

この API は古くから実装されているため、I/O は非同期 API として実装するという現在の大前提に反していながらも、互換性維持のためにそのままになっている。

しかし、後発の Service Worker は、その中で同期の I/O API の提供を許可しない。 document.cookie や LocalStorage が使えないのはそのためだ。

LocalStorage は IndexedDB で代替できるが、 Cookie へのアクセスは代替がない。しかし、 SW からも Cookie にアクセスしたい要求があった。

document.cookie は単なる setter/getter であるため、値をシリアライズした文字列で扱う。

文字列のパース/シリアライズは、全てユーザランドで実装する必要があるのだ。

ちゃんとやるなら RFC に書かれた BNF をベースに実装することになるが、多くの場合 ;= で Split する手軽な実装で間に合わせられているだろう。

const cookie = new Map(
  document
    .cookie
    .split(`;`)
    .map((e) => e.trim().split("="))
)

逆も同様だ。

document.cookie = [
  `__Host-session_id=deadbeef`,
  `Max-Age=${60 * 60 * 24}`,
  `Secure`
].join("; ")

また、例えば document.cookie 経由で HttpOnly な Cookie を Set しようとしたりしても、特にエラーにはならない。

もちろん、ブラウザの中には Battle Tested な Cookie のパース/シリアライズロジックが入っているにもかかわらず、それを叩く API が提供されていなかった。

Cookie を明示的に削除する API は存在せず、 Expires を過去にするか Max-Age=0 にするといった方法が必要になる。これは、 Set-Cookie ヘッダでも同様だ。

Clear-Site-Data を用いればスコープ内の Cookie 全てを消すことは可能だが、こちらは特定の値を狙って消すことはできない。

そこで、 Cookie にアクセス可能な非同期 API として Async Cookie API の策定が始まり、そこに様々な負債を解消するためのプリミティブが詰め込まれ、 Cookie Store API と名前を変えて今に至る。

set

基本的な Set は以下のようになる。

await cookieStore.set("__Host-session_id", "deadbeef")

属性を指定する場合は Object を渡す。

await cookieStore.set({
  name: "__Host-session_id",
  value: "deadbeef",
  expires: Date.now() + 1000 * 60 * 60 * 24,
  path: "/",
})

設定していない属性はデフォルト値になる。

get

取得は名前を指定して行う。

await cookieStore.get("__Host-session_id")
// {
//   name: "__Host-session_id",
//   value: "deadbeef",
//   expires: Date.now() + 1000 * 60 * 60 * 24,
//   domain: null,
//   path: "/",
//   secure: true,
//   sameSite: "lax"
// }

同一名で複数の Cookie が付与されている場合は、 getAll で全て取得できる。

await cookieStore.getAll("__Host-session_id")

また、 Cookie が Path のスコープであるため、 URL ベースでクエリすることもできるようになっている。

await cookieStore.getAll({ url: "/admin" })

delete

Cookie には HTTP にも document.cookie にも「削除」の API はなく、過去の日付で上書きするといった方法が取られていた。

この仕様には、明示的な delete() が定義されている。

await cookieStore.delete("__Host-session_id")

名前以外に、 domain / path でもできる。

await cookieStore.delete({ path: "/admin" })

onchange

ドキュメント内での Cookie の変更をイベントで取得できる。

cookieStore.addEventListener("change", (event) => {
  const changed = event.changed // 変更された Cookie のリスト
  const deleted = event.deleted // 削除された Cookie のリスト
})

このイベントは Service Worker からも取得できる。

しかし、すべての Cookie の変更のたびに SW を起動するとコストが高いため、特定の Cookie の変更をあらかじめ Subscribe する必要がある。

self.addEventListener("install", (event) => {
  event.waitFor(async () => {
    await cookieStore.subscribeToChanges([
      {
        // "session" で始まる Cookie の変更を Subscribe
        name: "session",
        matchType: "starts-with"
      }
    ])
  })
})

self.addEventListener("cookiechange", (event) => {
  const changed = event.changed // 変更された Cookie のリスト
  const deleted = event.deleted // 削除された Cookie のリスト
})

仕様の論点

現在 Chrome は Ship 済みだが、 Firefox / Safari は実装しておらず、 Position も blocked になっている。

論点としては、そもそも Cookie についてまだ解決されてない互換性上の問題があり、それが Cookie Store API によって解決し切れているとはいいきれないという主張のようだ。

それがはっきりしない限り Firefox / Safari のスタンスは変わらなそうだ。しかし、関連する Issue の議論も止まっているようだ。

Outro

そもそも、 Storage 系の API が整備されており他にも選択肢があるため、 Cookie は HttpOnly を付与するのが基本で、 JS からアクセスする機会はかなり減っている。

そして、 HttpOnly な Cookie は document.cookie 同様 Set はできても Get/Subscribe できないため、ユースケースはかなり絞られたものになるだろう。

Cookie 周りのプリミティブが整理され、ぽっかり空いていた穴が埋められている点に一定の価値はあるが、他のブラウザの実装は進んでおらず、議論もしばらく止まっているようなので、今後どうなるのかは不明だ。

Resources