Next.js и исключения в Server Action
Если вы работали с Server Actions в Next.js, то знаете, насколько это удобно. Вызов серверных функций из клиента без RPC, возможность скрыть часть логики для тупой кнопки, валидация форм на сервере или просто способ скрыть API ключи и внутренний сервис. В общем очень круто... пока вы не начнете выкидывать ошибки, ожидая поймать их в клиенте.
Посмотрим на этот кусок кода для входа через Supabase (не забываем "use server"):
Выглядит нормально, наверное на стороне клиента мы ждем что-то типа
И это абсолютно правильно, ну по крайней мере в Dev-окружении. Когда вы решите собрать продакшн-сборку, то столкнетесь с очень странным феноменом, от которого полыхает у половины юзеров Next.js:
Опа... а где ошибка? Так вот же она, только у нее отобрали трейс и сообщение, оставили только хэш ошибки. Как говорят разрабочики из 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 - а тут спидран пройден.