April 25

Next.js и исключения в Server Action

Если вы работали с Server Actions в Next.js, то знаете, насколько это удобно. Вызов серверных функций из клиента без RPC, возможность скрыть часть логики для тупой кнопки, валидация форм на сервере или просто способ скрыть API ключи и внутренний сервис. В общем очень круто... пока вы не начнете выкидывать ошибки, ожидая поймать их в клиенте.

Посмотрим на этот кусок кода для входа через Supabase (не забываем "use server"):

Выглядит нормально, наверное на стороне клиента мы ждем что-то типа

И это абсолютно правильно, ну по крайней мере в Dev-окружении. Когда вы решите собрать продакшн-сборку, то столкнетесь с очень странным феноменом, от которого полыхает у половины юзеров Next.js:

An error occured in the Server Components render. The specific message is ommited in production builds...

Опа... а где ошибка? Так вот же она, только у нее отобрали трейс и сообщение, оставили только хэш ошибки. Как говорят разрабочики из Vercel, "ето штобы вы дурачки трейсы с ключами апи и паролями клиентам не возвращали". И даже если вы ловите ошибки в Sentry, а клиенту кидаете что-то типа

try {
    ...
} catch (e) {
    sentry.pushEvent(e as Error)
    throw new Error("Ты дурачок? Ты как нам бэк положил...")
}

то все-равно ваша ошибка будет съедена. Что делать? Передавать ошибки как пропы объектов. То есть теперь все Server Actions должны возвращать что-то вроде

type ActionReturnValue<T> = {
    data?: T,
    error?: Error
}

Но это ужас, а если мы где-то получим ошибку, где мы ее даже технически не ожидали? А что делать с этим дуализмом, когда наше значение превращается в

const {data, error} = await myServerAction(...)
if (e && e instanceof Error) {
    // Shitty error bruh
    ...
    return
}
const username = data?.username ?? "Unknown" // или data!.username

Это кринж полный. Однако это единственный выход. Правда можно все красиво оформить...

Вот это копипастите к себе в проект, я сам написал, оно работает отлично и имхо лучше вариантов из гитхаба и статей на Medium:

export class ActionResponse {
    static success<T = unknown>(data: T): ActionData<T> {
        return {
            data
        }
    }

    static error<T = null>(error: Error): ActionData<T> {
        return {
            error: error.message
        }
    }

    static unwrap<T = null>(data: ActionData<T>): T {
        if (data.error)
            throw new Error(data.error)
        return data.data!
    }

    static wrap<P extends (...args: any) => any>(func: P): ForwardedAsyncFunction<P> {
        return async (...args: ArgumentTypes<P>) => {
            try {
                return ActionResponse.success(await func(...args))
            } catch (error) {
                return ActionResponse.error(error as Error)
            }
        }
    }
}

export type ActionData<T = unknown> = {
    data?: T,
    error?: string
}


type ForwardedAsyncFunction<F extends (...args: any) => any> =
    (...args: ArgumentTypes<F>) => Promise<ActionData<Awaited<ReturnType<F>>>>;


type ArgumentTypes<F extends Function> = 
    F extends (...args: infer A) => unknown ? A : never;

И так наш пример с логином превращается в

Да, unwrap извлекает возвращаемое значение {data} со всей типизацией.

Завернуть функцию в Actions и развернуть на клиенте.

В принципе все, я просто опять наткнулся на unwrap в своем коде и подумал, что неплохо бы поделиться таким решением, так как я сутки потратил на мучение документации и проблем на гите Next.js - а тут спидран пройден.