🔯

Next.js + Supabase + Vercel + Inngestでバックグラウンドジョブからユーザー認証してDBを操作

2023/06/14に公開

はじめに

VercelでホストしているNext.js(v12)+ Supabase製のアプリにバックグラウンドジョブを実装した際、Inngest というサービスを使ったらいい感じにできたのでやったことをまとめておきます。

InngestのVercelへのインテグレーションは 公式のマーケットプレイス用意されており、簡単に連携させることができます。

前提

各種ライブラリのバージョンはこんな感じです。

{
  "dependencies": {
    "next": "12.3.4",
    "@supabase/supabase-js": "2.22.0",
    "@supabase/auth-helpers-nextjs": "0.7.2",
    "@supabase/auth-helpers-react": "0.4.0",
    "inngest": "2.0.1"
  },
  "devDependencies": {
    "inngest-cli": "0.14.0"
  }
}

すでにNext.js(v12)+ Supabase製のアプリがVercelでホストされている状態で、Inngestを導入してバックグラウンドジョブを実装する部分の手順を解説します。

大まかな流れは

この辺を眺めれば大体分かるようになっています。

1. InngestのSDKおよびCLIツールをインストール

$ yarn add inngest
$ yarn add -D inngest-cli

2. Inngestサーバーに対するクライアントとなるAPIエンドポイントを追加

// pages/api/inngest.ts

import {serve} from 'inngest/next'
import {inngest} from '../../inngest/client'

export default serve(inngest, [/* ここにジョブを処理する関数を列挙 */])

このエンドポイントがInngestサーバーに対するクライアントとなります。(ので、サイト全体にBASIC認証を掛けている場合などは、このエンドポイントをBASIC認証の対象から外しておく必要があります)

クライアントの生成自体はあとあとコードの見通しを良くするために別ファイルに分けておきます。

// inngest/client.ts

import {Inngest} from 'inngest'

const inngestOptions = {  
  name: '{アプリ名}',
}

export const inngest = new Inngest(inngestOptions)

3. ジョブを処理する関数を実装

続いて、実際にジョブを処理する関数を実装していきます。

まずはAPIエンドポイントとクライアントのコードを以下のように修正します。

  // pages/api/inngest.ts
  
  import {serve} from 'inngest/next'
  import {inngest} from '../../inngest/client'
+ import helloJob from '../../inngest/hello'
  
- export default serve(inngest, [/* ここにジョブを処理する関数を列挙 */])
+ export default serve(inngest, [helloJob])
  // inngest/client.ts
  
- import {Inngest} from 'inngest'
+ import {EventSchemas, Inngest} from 'inngest'
+ import {HelloEvent, helloEventName} from './hello'
+
+ // ①
+ export type Events = {
+   [helloEventName]: HelloEvent
+ }
  
  const inngestOptions = {  
    name: '{アプリ名}',
+   schemas: new EventSchemas().fromRecord<Events>(), // ②
  }  
    
  export const inngest = new Inngest(inngestOptions)

inngeset/hello.ts に関数を実装する想定です。

①の箇所は

{
  イベント名: イベントを処理する関数に渡されるデータの型
}

を定義していて、これを②でジェネリクス引数に渡すことで、関数の実装コード側で型推論が効くようになります。

参考:TypeScript - Inngest Documentation

①の箇所ではハードコードせず、hello.ts 側で定義することでDRYにしています。

では、hello.ts の内容を見ていきましょう。

// inngest/hello.ts

import {inngest} from './client'

export const helloEventName = '{アプリ名}/hello'

export type HelloEvent = {
  name: typeof helloEventName
  data: {
    name: string
  }
}

export default inngest.createFunction(
  {name: helloEventName},
  {event: helloEventName},

  // 渡されたデータを使った実際の処理
  async ({event}) => {
    console.log(`Hello, ${event.data.name}!`)
  },
)

一旦の例として骨組みだけの内容で書くとこんな感じです。

この例では、渡された data.name を使って Hello, {渡された名前}! をコンソールに出力するという内容になっています。

4. 実際にジョブを実行してみる

あとは、例えば以下のようなジョブをキューするためのAPIエンドポイントを用意すればOKです。

// pages/api/hello.ts

import type {NextApiRequest, NextApiResponse} from 'next'
import {inngest} from '../inngest/client'
import {helloEventName} from '../inngest/hello'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  await inngest.send({
    name: helloEventName,
    data: {
      name: 'Takashi'
    },
  })

  res.status(200).json({status: 'success'})
}

詳細は後述しますが、クライアントからInngestサーバーにイベントを送信するには、INNGEST_EVENT_KEY という環境変数に "Event Key" と呼ばれる特定の値が格納されている必要があります。

ローカル開発環境においては、値はなんでもよいので、.env.local

INNGEST_EVENT_KEY=local

などと書いておけばOKです。

この状態で、

$ yarn inngest-cli dev

でローカルに開発環境用のInngestサーバーを立ち上げ、http://localhost:3000/api/hello にアクセスしてみると、yarn dev しているコンソールに Hello, Takashi! が出力されます。

5. ジョブ処理関数にSupabaseのユーザーログインセッションを渡してDBを操作できるようにする(ジョブをキューするところまで)

さて、ここまでで基本的な流れは完成です。

続いて、ジョブ処理関数にSupabaseのユーザーログインセッションを渡して、ジョブからDBの操作などをできるようにしてみましょう。

まず前提として、以下のような形の jobs テーブルが用意されているとします。

CREATE TABLE "public"."jobs" (
  "id" int8 NOT NULL,
  "uuid" uuid NOT NULL,
  "status" varchar NOT NULL,
  "context" json,
  "started_at" timestamptz,
  "ended_at" timestamptz,
  "created_at" timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT "jobs_uuid_fkey" FOREIGN KEY ("uuid") REFERENCES "auth"."users"("id"),
  PRIMARY KEY ("id")
);
  • uuid:そのジョブの実行者であるユーザーのid
  • status:ジョブの実行状況(waiting runnning finished failed といった文字列)
  • context:ジョブに渡すデータやジョブの実行結果など
  • started_at ended_at:それぞれジョブの開始・終了日時

を入れるイメージです。

この上で、ジョブをキューするAPIエンドポイントを以下のように修正します。

// pages/api/hello.ts

import {createPagesServerClient} from '@supabase/auth-helpers-nextjs'
import {createClient} from '@supabase/supabase-js'
import type {NextApiRequest, NextApiResponse} from 'next'
import {inngest} from '../inngest/client'
import {helloEventName} from '../inngest/hello'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // ①
  const {data: {session}} = await createPagesServerClient({req, res}).auth.getSession()

  if (session === null) {
    res.status(403).json({status: 'error', message: 'ログインしてください。'})
    return
  }

  // ②
  const adminClient = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
    process.env.SUPABASE_SERVICE_ROLE_KEY ?? '', // という環境変数を設定してあるとして
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    },
  )

  // ③
  const job = await insertJob({
    uuid: session.user.id,
    status: 'waiting',
    context: {
      name: 'Takashi',
    },
  }, adminClient) // みたいな関数を作ってあるとして🙏

  // ④
  await inngest.send({
    name: helloEventName,
    data: {
      accessToken: session.access_token,
      refreshToken: session.refresh_token,
      jobId: job.id
    },
  })

  res.status(200).json({status: 'success'})
}

まず①でログインセッションの内容を取得します。

続いて②でSupabaseのadminクライアントを生成します。これは、jobs テーブルへのレコードの挿入はadminだけしかできないように RLS で制約されていることが設計上妥当なので、その想定によるものです。

参考:Supabase Javascript Client #Auth Admin

そして③でそのadminクライアントを使って jobs テーブルにレコードを追加します。

最後に④でジョブをキューしますが、このとき、ジョブにはログインセッションのアクセストークンとリフレッシュトークン、および今しがた追加したジョブのidをデータとして渡すようにします。

ジョブ処理関数側では、これらを受け取ってログインセッションを復元し、その上で指定されたidのジョブを橋渡しにして処理を行うという流れになります。

6. ジョブ処理関数の実装を修正

では hello.ts の内容を見てみましょう。ちょっと長いですが一気に書いちゃいます。

// inngest/hello.ts

import {createClient} from '@supabase/supabase-js'
import {inngest} from './client'

const adminClient = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
  process.env.SUPABASE_SERVICE_ROLE_KEY ?? '', // という環境変数を設定してあるとして
  {
    auth: {
      autoRefreshToken: false,
      persistSession: false,
    },
  },
)

export const helloEventName = '{アプリ名}/hello'

// ①
export type HelloEvent = {
  name: typeof helloEventName
  data: {
    accessToken: string
    refreshToken: string
    jobId: number
  }
}

export default inngest.createFunction(
  {name: helloEventName},
  {event: helloEventName},

  async ({event}) => {
    // ②
    const client = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
      {auth: {persistSession: false}},
    )
    await client.auth.setSession({
      access_token: event.data.accessToken,
      refresh_token: event.data.refreshToken,
    })

    const user = (await client.auth.getUser()).data.user

    if (!user) {
      throw 'ユーザーが見つかりませんでした。'
    }

    // ③
    const job = await selectJob({id: event.data.jobId}, client) // みたいな関数を作ってあるとして🙏

    if (!job) {
      throw 'ジョブが見つかりませんでした。'
    }

    if (user.id !== job.uuid) {
      throw '指定されたジョブとログインユーザーが一致しませんでした。'
    }

    // ④
    try {
      await start(job.id)
      console.log(`Hello, ${job.context.name}!`)
      await finish(job.id)
    } catch (err) {
      await fail(job.id, err)
    }
  },
)

const start = async (jobId: number): Promise<void> => {
  await updateJob({
    id: jobId,
    status: 'running',
    started_at: new Date(),
  }, adminClient)
}

const finish = async (jobId: number, context?: object): Promise<void> => {
  await updateJob({
    id: jobId,
    status: 'finished',
    context,
    ended_at: new Date(),
  }, adminClient)
}

const fail = async (jobId: number, error?: object) => {
  await updateJob({
    id: jobId,
    status: 'failed',
    context: error,
    ended_at: new Date(),
  }, adminClient)
}

まず①で HelloEvent 型の定義を修正してあります。(あらかじめDRYにしておいたおかげで修正箇所はここだけで済みましたね👌)

②で、アクセストークンとリフレッシュトークンからログインセッションを復元したSupabaseクライアントを生成しています。

createClient() の第3引数に {auth: {persistSession: false}} を渡さないとその後 setSession() してもセッションが復元されないという罠があるので要注意です。(ハマりました)

参考:supabase.auth.setSession doesn't set and persist session for the current client · Issue #474 · supabase/gotrue-js #issuecomment-1353637328

③で、受け取ったジョブidをもとに実際にDBからジョブを取得しています。

最後に④で

  1. ジョブのステータスを running に更新
  2. ジョブの処理を実行
  3. 成功した場合はジョブのステータスを finished に更新
  4. 失敗した場合はジョブのステータスを failed に更新

を行っています。

今回の例では実処理がただの console.log() なので一瞬で finished になってしまいますが、本来なら長時間かかる処理を行うはずなので、DBを通してジョブの進行状況が分かるようにしておけば何かと便利という想定です。

これで、Next.js(v12)+ Supabase + Inngestでバックグラウンドジョブからユーザー認証してDBを操作する実装ができました🙌

7. Inngestサーバーに関数をデプロイ

ローカル開発環境での作業は以上です。

最後に、Inngestへのデプロイの手順について軽く触れておきます。

まず https://vercel.com/integrations/inngest ここからInngestインテグレーションをVercelプロジェクトにインストールします。

インストール後、Inngestの下図の画面で目的のVercelプロジェクトのスイッチをONにしてください。

次に、Inngest側で発行された "Signing Key" と "Event Key" というものをVercelに環境変数として登録します。

https://www.inngest.com/docs/deploy/vercel によるとインテグレーションをインストールすればこれらの環境変数も自動で登録されるはずのようなのですが、なんか僕の環境だとそうはならなかったので手動で登録しました🤔
色々試行錯誤してたのでその過程でなんかおかしくなってたのかもしれません。

最終的にVercelに登録した環境変数は下図の4つです。

僕のプロジェクトではGitHubの release ブランチを本番環境に、main ブランチをステージング環境にアサインしており、Inngestとの連携を有効にするのもこの2つの環境だけでよいと判断してあえて環境変数の範囲を制限しています。

それぞれInngestの下図の画面で得られます。

INNGEST_EVENT_KEY (Preview on main)

INNGEST_EVENT_KEY (Production)

INNGEST_SIGNING_KEY (Preview on main)

INNGEST_SIGNING_KEY (Production)

参考:

この状態でVercelへのデプロイを実行すると、Inngest関数がInngestサーバーに自動でデプロイされ、VercelとInngestの連携が動作し始めます👌

以上、お疲れさまでした!

その他参考

「Inngest使わなくてもEdge Functionsで waitUntil() すればバックグラウンドでタスク実行できるよね(意訳)」という言及がなされていますが、Edge Functionsは waitUntil() 内の処理であっても30秒でタイムアウトして中断されてしまう ので、30秒以上かかるタスクを実行したい場合は別途ジョブサーバーが必要になります。

GitHubで編集を提案

Discussion