🦊

【ElysiaJS】Honoからの移行でハマった!Middlewareとスコープレベルの重要性

に公開

はじめに

今回、HonoからElysiaJSにリプレイスした際に、詰まったポイントと解消法について記載します。

リプレイスの経緯

リプレイスの経緯は以下2つになります。

  1. 優れたパフォーマンス: ベンチマーク上で高いパフォーマンスを示している
  2. OpenAPI準拠: デフォルトでOpenAPIに対応している
  3. 保守性: 記述量をHonoより減らせる

bench mark for comparison between Hono and ElysiaJS

この3点からHonoからElysiaJSへのリプレイスを行いました。

https://elysiajs.com/migrate/from-hono.html

それでは本題に移ろうと思います。

注意点

早速、注意点ですが、Middlewareのスコープを明示する必要があるというものです。

実際、私が遭遇したエラーは、Controller層にてMiddlereの認証情報としてuser情報を受け取ろうとした際に発生しました。

以下が、コンパイルエラーの詳細です。

プロパティ 'user' は型 '{ body: unknown; query: Record<string, string>; params: { userId: string; }; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; ... 7 more ...; status: SelectiveStatus<...>; }' に存在しません。

https://elysiajs.com/migrate/from-hono#middleware

src/features/users/api/index.ts
import { Elysia, t } from 'elysia'
import { sessionMiddleware } from '~/lib/session-middleware'
import { UserNotFoundError, UserServiceError } from './errors'
import { UserModel } from './model'
import { UserService } from './service'

export const userPlugin = new Elysia({ prefix: '/users', name: 'user' })
  .use(sessionMiddleware)
  .error({
    UserServiceError,
    UserNotFoundError,
  })
  .onError(({ code, error, set }) => {
    switch (code) {
      case 'UserServiceError':
        set.status = error.status || 500

        return {
          error: error.message,
          code: 'USER_SERVICE_ERROR',
        }
        
      case 'UserNotFoundError':
        set.status = error.status || 404

        return {
          error: error.message,
          code: 'USER_NOT_FOUND',
        }

      default:
        throw error
    }
  })
  .get(
    '/:userId',
+    async ({ params, user }) => {
      const userId = params.userId || user.id

      const result = await UserService.getUser({ userId })

      return result.user
    },
    {
      params: UserModel.getUserQuery,
      response: {
        200: UserModel.user,
        401: UserModel.errorResponse,
        404: UserModel.errorResponse,
        500: UserModel.errorResponse,
      },
      detail: {
        tags: ['Users'],
        summary: 'ユーザー詳細取得',
        description: '指定されたIDのユーザーの詳細情報を取得します。',
      },
    },
  )

順を追って説明していきます。
まず、session-middleware.tsのBefore・Afterから見てみましょう。

Before

元のSessionMiddlewareですが、以下のようになっていました。

session-middleware.ts
import 'server-only'

import { eq } from 'drizzle-orm'
import { createMiddleware } from 'hono/factory'
import { ERROR_STATUS } from '~/constants/error-message'
import { users } from '~/db/schema'
import { db } from '~/index'

type AdditionalContext = Record<
  'Variables',
  {
    user: typeof users.$inferSelect
  }
>

export const sessionMiddleware = createMiddleware<AdditionalContext>(async (c, next) => {
  const userId = c.req.header('Authorization')

  if (!userId) {
    return c.json({ error: { message: ERROR_STATUS.UNAUTHORIZED } }, 401)
  }

  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
  })

  if (!user) {
    return c.json({ error: { message: 'User not found' } }, 404)
  }

  c.set('user', user)

  await next()
})

After

そして、これがElysiaJSでのsessionMiddlereになります。
至って問題がないように思えます。

session-middleware.ts
import { eq } from 'drizzle-orm'
import { Elysia } from 'elysia'
import { ERROR_STATUS } from '~/constants/error-message'
import { users } from '~/db/schema'
import { getDb } from '~/index'

class UnauthorizedError extends Error {
  status = 401

  constructor() {
    super(ERROR_STATUS.UNAUTHORIZED)
    this.name = 'UnauthorizedError'
  }
}

class UserNotFoundError extends Error {
  status = 404

  constructor() {
    super('User not found')
    this.name = 'UserNotFoundError'
  }
}

export const sessionMiddleware = new Elysia({ name: 'session' })
  .error({
    UnauthorizedError,
    UserNotFoundError,
  })
  .onError(({ code, error, set }) => {
    switch (code) {
      case 'UnauthorizedError':
        set.status = error.status

        return {
          error: error.message,
          code: 'UNAUTHORIZED',
        }

      case 'UserNotFoundError':
        set.status = error.status

        return {
          error: error.message,
          code: 'USER_NOT_FOUND',
        }

      default:
        throw error
    }
  })
  .derive(async ({ headers }) => {
    const userId = headers.authorization

    if (!userId) {
      throw new UnauthorizedError()
    }

    const user = await getDb().query.users.findFirst({
      where: eq(users.id, userId),
    })

    if (!user) {
      throw new UserNotFoundError()
    }

    return { user }
  })

ではなぜコンパイルエラーがでたのでしょうか。

解決法: as('scoped')を追加

このコンパイルエラーを解決するにはas('scoped')を追加する必要がありました。

session-middleware.ts
import { eq } from 'drizzle-orm'
import { Elysia } from 'elysia'
import { ERROR_STATUS } from '~/constants/error-message'
import { users } from '~/db/schema'
import { getDb } from '~/index'

class UnauthorizedError extends Error {
  status = 401

  constructor() {
    super(ERROR_STATUS.UNAUTHORIZED)
    this.name = 'UnauthorizedError'
  }
}

class UserNotFoundError extends Error {
  status = 404

  constructor() {
    super('User not found')
    this.name = 'UserNotFoundError'
  }
}

export const sessionMiddleware = new Elysia({ name: 'session' })
  .error({
    UnauthorizedError,
    UserNotFoundError,
  })
  .onError(({ code, error, set }) => {
    switch (code) {
      case 'UnauthorizedError':
        set.status = error.status

        return {
          error: error.message,
          code: 'UNAUTHORIZED',
        }

      case 'UserNotFoundError':
        set.status = error.status

        return {
          error: error.message,
          code: 'USER_NOT_FOUND',
        }

      default:
        throw error
    }
  })
  .derive(async ({ headers }) => {
    const userId = headers.authorization

    if (!userId) {
      throw new UnauthorizedError()
    }

    const user = await getDb().query.users.findFirst({
      where: eq(users.id, userId),
    })

    if (!user) {
      throw new UserNotFoundError()
    }

    return { user }
  })
+  .as('scoped')

なぜas('scoped')が必要なのでしょうか。

as('scoped') が必要な理由

ElysiaJSでは、.derive()で追加したプロパティは、デフォルトではlocalスコープとなるためです。

https://elysiajs.com/essential/plugin.html#instance-as

つまり、親インスタンスに型情報が伝播せず、結果としてこのコンパイルエラーとなるというのがこのエラーの実態です。

// ❌ as('scoped') なし - 型エラー発生
export const sessionMiddleware = new Elysia({ name: 'session' })
  .derive(async ({ headers }) => {
    return { user }
  })
  // as('scoped') がない!

// 親インスタンスで使用
const app = new Elysia()
  .use(sessionMiddleware)
  .get('/profile', ({ user }) => user) // ❌ 'user' が型に存在しない
// ✅ as('scoped') あり - 正常に動作
export const sessionMiddleware = new Elysia({ name: 'session' })
  .derive(async ({ headers }) => {
    return { user }
  })
  .as('scoped') // スコープを明示!

// 親インスタンスで使用
const app = new Elysia()
  .use(sessionMiddleware)
  .get('/profile', ({ user }) => user) // ✅ user が型推論される

では、スコープとはなんでしょうか。

スコープの種類

ElysiaJSには3つのスコープレベルがあります。

https://elysiajs.com/essential/plugin.html#scope-level

具体的には、以下です。

スコープ 適用範囲
local (デフォルト) 現在のインスタンスのみ
scoped 現在のインスタンス + 直接の親インスタンス
global すべての親インスタンス

そして、前述したように、derive()で追加したプロパティはデフォルトではlocalスコープとなります。

そのため、derive()を使用する際はスコープも意識して、実装をする必要があるということです。

スコープ・as()のまとめ

項目 説明
役割 プラグインのスコープを制御し、型情報を親インスタンスに伝播させる
必要な場面 .derive()で追加したプロパティを親インスタンスで使いたいとき
効果 TypeScriptの型推論が正しく機能し、コンテキストプロパティが認識される
省略時の問題 型エラーが発生し、プロパティがundefinedとして扱われる

重要ポイント

as('scoped')は、「このミドルウェアで追加したプロパティやフックを、このミドルウェアを使用する親インスタンスでも型安全に利用可能にする」という宣言です。

.derive()を使ってコンテキストにプロパティを追加する場合は、必ずas()を追加してスコープ管理することを忘れないようにしましょう。

参考文献

https://elysiajs.com/at-glance.html

Discussion