Open4

Hono Adapter Helper の Next.js 版 (Next.js Adapter Helper) をメモする

CureCure

概要

Hono.js の Adapter Helper や Next.js の cookies には、便利な関数が用意されている。
自分は、Web Standards を大事にしているが、MDN の Cookie とそれぞれミスマッチがある。

そこで、next/adapter なるものを自作することで、楽をしたい。

CureCure

技術的には、以下を想定

Web Standards だと思っているので、

  • session cookie で session のやり取りをする。
  • server actions で form のやり取りをする。
    => cookie forwarding なる問題が発生していそう

frontend

  • Next.js with server actions
  • fetch with Session Cookie

backend

  • Hono.js REST
  • set Session Cookie

つまり、React <-> server actions (with Session Sookie) <-> Hono.js REST 間で API と cookie のやり取りがある。

CureCure

server actions で ブラウザ?から cookie を取得して、fetch に cookie を埋め込むには?
(参考記事 にある Client to BackendのCookie Forwarding かも)

document.cookie -> Next.js fetch headers cookie 形式の変換。

next-app/src/libs/next/adapter/getHeadersCookie.ts
import { cookies } from 'next/headers'

/**
 * Headers の cookie 文字列を取得する adapter 関数
 *
 * @param cookieNames {string[]} cookie.name の配列
 *
 * @example
 * ```ts
 * const headerCookie = getHeaderCookie(['__Host-session', 'test'])
 * // __Host-session=abcde; test=12345 // for fetch headers.cookie
 * // fetch(url, { headers: { cookie: headersCookie } })
 * ```
 */
export function getHeadersCookie(cookieNames: string[]): string {
  const headersCookie = cookies()
    .getAll()
    .flatMap((cookie) =>
      cookieNames.includes(cookie.name)
        ? [`${cookie.name}=${cookie.value}`]
        : [],
    )
    .join('; ')

  return headersCookie
}

CureCure

server actions で API のレスポンスから cookie を取得して、ブラウザ?に cookie を設定するには?
(参考記事 にある Backend to ClientのCookie Forwarding かも)

Hono.js (response set-cookie) -> Next.js cookies() で cookieString を set する。

next-app/src/libs/next/adapter/setCookies.ts
import { parseSetCookie } from '@/libs/set-cookie-parser'
import { cookies } from 'next/headers'

/**
 * クッキーを設定する adapter 関数
 *
 * @param cookieString {string} response.headers.get('set-cookie')
 *
 * @example
 * ```ts
 * const response = await fetch()
 * setCookies(response.headers.get('set-cookie') ?? '')
 * ```
 */
export function setCookies(cookieString: string): void {
  const cookieInfos = parseSetCookie(cookieString)

  cookieInfos.map(({ name, options, value }) => {
    cookies().set(name, value, options)
  })
}
next-app/src/libs/set-cookie-parser/parseSetCookie.ts
import camelCase from 'just-camel-case'

// クッキーのオプションを表すインターフェース
interface CookieOptions {
  [key: string]: string | boolean
}

// クッキーを表すインターフェース
interface Cookie {
  name: string
  value: string
  options: CookieOptions
}

function splitCookiesString(cookieString: string): string[] {
  // 改行または複数の Set-Cookie ヘッダーを考慮して分割
  return cookieString.split(/\r?\n/).filter((line) => line.trim() !== '')
}

function parseCookie(cookieString: string): Cookie {
  const [rawCookie, ...optionsParts] = cookieString.split(/; */)
  const [name, value] = rawCookie.split('=')
  const options = optionsParts.reduce((acc, part) => {
    const [key, val] = part.split('=')
    acc[camelCase(key)] = val ?? true
    return acc
  }, {} as CookieOptions)

  return { name, value, options }
}

function parseCookies(cookiesArray: string[]): Cookie[] {
  return cookiesArray.map((cookieString) => parseCookie(cookieString))
}

/**
 * Set-Cookie ヘッダーの文字列をパースしてオブジェクトに変換する
 * 
 * HACK: generated by GPT で、コードの質が低い
 * 
 * ```ts
 * const cookieString = `sessionId=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Path=/; Secure; HttpOnly
userId=xyz789; Path=/; Secure; HttpOnly`
 * const cookies = parseSetCookie(cookieString)
 * // [
 * //   {
 * //     name: "sessionId",
 * //     value: "abc123",
 * //     options: {
 * //       expires: "Wed, 09 Jun 2021 10:18:14 GMT",
 * //       path: "/",
 * //       secure: true,
 * //       httpOnly: true,
 * //     },
 * //   }, {
 * //     name: "userId",
 * //     value: "xyz789",
 * //     options: {
 * //       path: "/",
 * //       secure: true,
 * //       httpOnly: true,
 * //     },
 * //   }
 * // ]
 * ```
 */
export function parseSetCookie(cookieString: string): Cookie[] {
  const cookiesArray = splitCookiesString(cookieString)
  return parseCookies(cookiesArray)
}