Next.js + Supabase + Vercel + Inngestでバックグラウンドジョブからユーザー認証してDBを操作
はじめに
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を導入してバックグラウンドジョブを実装する部分の手順を解説します。
大まかな流れは
- Quick start - Inngest Documentation
- Writing Functions - Inngest Documentation
- Vercel - Inngest Documentation
この辺を眺めれば大体分かるようになっています。
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
に関数を実装する想定です。
①の箇所は
{
イベント名: イベントを処理する関数に渡されるデータの型
}
を定義していて、これを②でジェネリクス引数に渡すことで、関数の実装コード側で型推論が効くようになります。
①の箇所ではハードコードせず、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 で制約されていることが設計上妥当なので、その想定によるものです。
そして③でその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()
してもセッションが復元されないという罠があるので要注意です。(ハマりました)
③で、受け取ったジョブidをもとに実際にDBからジョブを取得しています。
最後に④で
- ジョブのステータスを
running
に更新 - ジョブの処理を実行
- 成功した場合はジョブのステータスを
finished
に更新 - 失敗した場合はジョブのステータスを
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秒以上かかるタスクを実行したい場合は別途ジョブサーバーが必要になります。
Discussion