👊

【暴力】Server-Timing HeadersをNext.jsで無理やり使う

2023/11/15に公開

前回に引き続きネク虐をしていきます。

この記事は大体 @saitoeku3 さん, yue4u さんの受け売りであることを予めお断りしておきます! ありがとうございます!!! 自分が見つけた部分はrequestオブジェクトが共有されてるやんけ!だけです。

Server Timingってなに?

Server-Timing - HTTP | MDN を読むかChatGPTに聞いてください

Next.js公式の動きは?

Feature Requestが送られてはいるけど実装はされていません
Add support for server-timing header · Issue #12382 · vercel/next.js

1リクエストに対する1グローバル状態を持つ方法

Next.jsでは、_appと_documentとpageの間ではContextを通じてオブジェクトをやり取りすることができます(非公式な方法です)

// pages/_app.tsx
YourApp.getInitialProps = async (ctx) => {
  const documentCtx = ctx.ctx
  documentCtx.CHIAN_BREAKER_PROPERTY = '治安悪'
}

// pages/_document.tsx
YourDocument.getInitialProps = async (ctx) => {
  console.log(ctx.CHIAN_BREAKER_PROPERTY) // => '治安悪'
}

Contextを通してごにょごにょやることで1ページ1グローバル状態みたいなことができます。

ただしこの方法は、getInitialPropsでのみ利用可能な方法で、getServerSidePropsではContextオブジェクトが共有されないため、利用できません。

しかし調べてみたところrequestオブジェクトは全てのContextで共通のオブジェクトが利用されているため、このオブジェクトをキーとして、1リクエストに対する1つのグローバル状態を共有することができそうです。

今回はrequestをキーにしてグローバル状態を保持する方針で実装してみます。
ざっと書き下すと以下のような雰囲気です。

// なんか適当なところ.tsx
export const perRequestStore = new WeakMap<{}, string>();

// pages/テキトーなページ.tsx
export async function getServerSideProps(ctx) {
  perRequestStore.set(ctx.req, 'こんにちは from gSSP')
}

// pages/gIPなテキトーなページ.tsx
Page.getInitialProps = async (ctx) => {
  perRequestStore.set(ctx.req, 'こんにちは from gIP')
}

// pages/_document.tsx
YourDocument.getInitialProps = async (ctx) => {
  // ...

  if (ctx.res) {
    console.log(perRequestStore.get(ctx.req))
    // => 'こんにちは from gSSP' とか 'こんにちは from gIP' が出る
  }

  // ...
}

ServerTimingを記録してシリアライズする処理を書く

ここまで書けば聡明なみなさまなら「みなまで言うな」と声を揃えていただけると思いますが、無粋に書きます

ServerTiming.ts
type ServerTimingEntry = {
  name: string
  start?: number
  dur?: number
  desc?: string
}

const serverTimingsStore = new WeakMap<{}, Map<string, ServerTimingEntry>>();

export const ServerTiming = {
  time(ref: WeakRef<{}>, name: string, desc?: string) {
    const ident = ref.deref()
    if (!ident) return
    
    const timings = serverTimingsStore.get(ident) ?? new Map()
    timings.set(name, { name, start: performance.now(), desc })
  },
  timeEnd(ref: WeakRef<{}>, name: string) {
    const ident = ref.deref()
    if (!ident) return
    
    const entry = serverTimingsStore.get(ident)?.get(name)
    if (entry?.start == null) throw new Error(`ServerTiming: Timer ${name} does not exist`)
    
    entry.dur = performance.now() - entry.start;
  },
  toHeaders(ref: WeakRef<{}>) {
    const ident = ref.deref()
    if (!ident) return
    
    const entries = serverTimingsStore.get(ident)?.get(name)
    if (!entries) return 
    
    return [
      'Server-Timing',
      entries.map(entry => {
        if (entry.dur == null) return null
      
        const desc = entry.desc ? `; desc="${entry.desc.replaceAll('"', '')}"` : '';
        return `${entry.name.replaceAll('/', '-')};dur=${entry.dur}${desc}`
      })
        .filter(s => s != null)
        .join(',')
    ] as const
  }
}
pages/_document.tsx
YourDocument.getInitialProps = async (ctx) => {
  // ...

  if (ctx.res) {
    const serverTimingHeader = ServerTiming.toHeaders(new WeakRef(ctx.req))
    ctx.res.setHeader(...serverTimingHeader)
  }

  // ...
}

あとはアプリの中のrequestが取れる場所で適当に呼ぶだけです。

Requestオブジェクトを変に保持しているとメモリリークの原因になります。例えばAPIリクエストの前後にフックを仕掛けて時間を計測したいときなど、遠いところからrequestを参照するときはWeakRefなどを使っておくと安心です。

以下の例のWeakRefは完全に蛇足です。

pages/なんか適当なページ.tsx
export async function getServerSideProps(ctx) {
  ServerTiming.time(new WeakRef(ctx.req), 'API Request')
  const data = await getSomeData()
  ServerTiming.timeEnd(new WeakRef(ctx.req), 'API Request')
  
  return { props: { data } }
}

VRoid HubではReduxにWeakRefを突っ込むことでActionからrequestを参照できるようにして、各APIの通信にかかった時間を計測するようにしています。

おわりです、よかったね。
大雑把に作ったから、細かい実装はあとからよしなに読み替えてね。<敬具 />

はなくら組代表 はなくら より

Discussion