Фальшиве відчуття безпеки або найбільший недолік Next.js
Вітаю, мене звати Кирил і я Senior Frontend Engineer. Десь рік тому я почав роботу над пет-проєктом Freeversity. Як фреймворк для нього я обрав нещодавній реліз Next.js 13.4 з App Router. І, відверто кажучи, за цей рік фреймворк підкидав мені дуже багато неприємних та неочевидних сюрпризів там, де все, на перший погляд, здавалось максимально прямолінійним.
Цей текст буде про все, що змушувало мене палати під час роботи з Next.js App Router. Колись пізніше мені б хотілося написати й про сильні сторони фреймворку. Але не виключено, що це буде стаття про міграцію на Remix.js :)
Я не маю на меті налаштувати вас проти цього фреймворка, а лише хочу розказати про наявні підводні камені в роботі з Next.js, з якими я стикався і на які витратив купу часу.
Щобільше, я не виключаю, що реалізація деяких фіч з мого боку була неправильною або неоптимальною. І я буду дуже вдячний, якщо небайдужі люди в коментарях скерують мене на шлях істини, запропонують кращі рішення або виправлять наявні фактичні помилки в статті, якщо такі є.
SSR та Next.js: Трохи контексту
Якщо у вас був досвід роботи з SSR React-застосунками, ви знаєте, що сама ідея полягає у виконанні реакт-коду на сервері для генерації HTML-розмітки та подальшим виконанням того самого коду (зібраного трошки інакше, але з тих самих вхідних файлів) на клієнті для «гідрації» розмітки в повноцінний інтерактивний React-застосунок.
В теорії все виглядає доволі просто: в нас є код, який ми пишемо один раз, а виконуємо двічі у двох різних рантаймах. Що може піти не так? А піти не так може багато чого: від бібліотек, які підтримують лише один рантайм до цілої купи ускладнень, пов’язаних з початковим завантаженням даних задля генерації розмітки на сервері, налаштуванням Lazy Loading (з коробки для SSR воно почало працювати лише з React 18) та більш критичного значення оптимізації продуктивності застосунку.
Популярність Next.js була обумовлена саме тим, що він одним з перших в екосистемі React надав готове середовище та запропонував ефективні варіанти розв’язання цих проблем. А також поступово обростав фічами, на кшталт SSG (Static Site Generation) та часткового SSG, ISR (Incremental Static Regeneration) тощо.
В травні 2023 року відбувся реліз Next.js 13.4, в якому App Router зазначався стабільним та рекомендованим підходом для подальшої роботи з Next.js. І саме з ним з цього моменту пропонувалося створювати всі нові застосунки.
З собою App Router також приніс купу довгоочікуваних фіч: Layouts, Streaming та React Server Components. RSC повинні були полегшити найбільший головний біль React SSR — гідрацію та купу JS-коду, який треба завантажувати на клієнт, навіть для компонентів без інтерактивності.
Саме в цей момент я починав роботу над Freeversity і вагався між Next.js та Remix. Після деяких тестових спроб я обрав Next.js через більшу популярність та фічі, які на той момент здавалися максимально сучасними та корисними. Все це йшло з екосистемою Vercel, що дозволяє парою кліків безкоштовно деплоїти Next.js-застосунок напряму з GitHub-репозиторію.
І тут ми перейдемо до головної фундаментальної проблеми Next.js.
Runtimes
В попередньому розділі ми згадували, що зазвичай SSR-застосунок виконується у двох рантаймах: браузер та Node.js-server. Це додає застосункам неабиякої складності, але у підсумку економить купу часу розробника.
Next.js йде далі. І до двох звичних рантаймів додається третий — Edge. В документації цій особливості приділяється не так багато уваги: розділ про рантайми, розділ про Edge. Це створює фальшиве відчуття безпеки, бо число 3 описує кількість рантаймів лише в теорії. Тоді як на практиці все набагато складніше.
Client
Тут нічого нового. Сюди добирається лише код компонентів, промаркованих директивою ‘use client’. Саме цей код виконує гідрацію та додає компонентам інтерактивність.
Edge Functions
В Edge-рантаймі виконується middleware, що є додатковим шаром перед обробкою будь-якого запита (до сторінок, чи до бекенд-ендпоїнтів). Middleware може виконувати редиректи, додавати куки та надсилати HTTP-запити. Загалом, все доволі схоже на звичний Route Handler, але доступу до БД тут не має, тож спілкуватися з сервером можна лише відправляючи запити через fetch.
Serverless Functions
Цей рантайм використовується для двох головних речей:
- Генерація розмітки з Server та Client-компонентів (так, Client-компоненти також виконуються і на сервері, ха!).
- Обробка бекенд API-запитів (Route Handlers в теці /app/api).
І тут починається пекло. Важливою є особливість, що для Route Handlers, Server та Client-компонентів доступні різні API та діють різні правила виконання.
Route Handlers необхідні для обробки запитів, комунікації з БД та відправки відповідей. Нічого нового, чи не так? Але першою магічною особливістю, яку ви побачите, буде те, що хедери та куки можна імпортувати як статичні файли. І для кожного запита магічним чином в імпортованих файлах будуть лише дані, які стосуються саме поточного запита. Як таке взагалі можливо?!
Можливо це завдяки тому, що це не просто Node.js-рантайм. Це Lambda-функція, контекст якої створюється і виконується окремо для кожного запита. Це значно зменшує ціну хостинга вашого застосунку, допоки користувачів у вас небагато. А також неймовірно спрощує масштабування, коли кількість користувачів збільшується.
Але й на цьому сюрпризи не закінчуються! Server та Client-компоненти, хоч і виконуються на сервері, не мають жодного доступу до back-end API (майже, ха!). До того ж, для комунікації з сервером ми повинні користуватися Server Actions або Fetch API і відправляти запити так, ніби код виконується в браузері.
При цьому API, доступні для Server та Client-компонентів, теж різняться: в серверних компонентах не можна використовувати хуки чи контекст, але можна читати куки та хедери. Клієнтські ж компоненти можуть мати стан, бути інтерактивними, але потребують гідрації та додаються до клієнтського бандла.
В підсумку ми маємо не три, а п’ять(!) різних середовищ виконання коду, в кожного з яких є свої особливості та доступні API. І саме з них випливає решта підводних каменів цього фреймворка.
HTTP-запити
Що може бути не так з HTTP-запитами? Навіть найпрадавніші сайти якось виконували цю роботу. Звісно, були проблеми, пов’язані з читабельністю асинхронного коду, керуванням стану запита та обробкою помилок. Але Next.js вивів цю проблему на новий рівень.
Отже, повертаємося до наших рантаймів:
Рантайм |
Доступні HTTP APIs |
Client |
|
Edge Functions |
|
Serverless Functions |
|
Що це значить на практиці?
Undici
По-перше, можливість помилки, яка поклала мій пет-проєкт на кілька днів і вбила купу часу на дебаг і розшифровку логів Vercel. Хоча в цей час я міг би писати якусь набагато менш емоційну статтю, скажімо, про оптимізацію продуктивності React-застосунків. Помилка, з якою я мав справу, чудово описана в статті Fix: Vercel + Next.JS «fetch failed» from undici. І найнеприємнішою її особливістю є те, що відтворюється вона найнеочікуванішим чином і лише на інфраструктурі Vercel.
Але що нам заважає відмовитись від використання вбудованого Fetch API з ненадійними поліфілами та встановити найпопулярнішу та багато разів перевірену кросплатформенну бібліотеку для HTTP-запитів? А саме, Axios! Таку рекомендацію можна час від часу побачити в топіках, пов’язаних з undici.
Axios
Це цілком робочий варіант аж до того моменту, поки вам не доведеться відправити HTTP-запит в Edge Function. Бо найпопулярніша бібліотека для HTTP-запитів за версією npm-trends працює лише в рантаймах, що мають XMLHttpRequest або Node.js HTTP Agent! І особисто в мене не вийшло запустити Axios в Edge з жодним з наявних Fetch API-адаптерів. Тож якщо вам судилося винести з цієї статті лише один корисний висновок, то ось він: не використовуйте Axios з Next.js App Router. Особисто я зупинився на cross-fetch.
UPD: В коментарях зазначили, що бета-версія axios підтримує Fetch API для відправки HTTP-запитів, тож є вірогідність, що після оновлення, axios з декими незручностями, але буде працювати в Edge-середовищі. Однак офіційно це середовище не підтримується, тож з використанням axios з Next.js все ще варто бути обачним.
Але і це не все.
Credentials
HTTP-запити по-різному виконуються для Client та Server-середовища. Запити з компонентів на клієнті автоматично містять в собі headers та cookies, тоді як в запити на сервері кредентіали треба передавати явно. І імпортувати їх можна виключно в коді, який виконується тільки на сервері. Тобто, для кожного такого запита передавати кредентіали треба аж з рівня Server Component, де їх можна імпортувати.
Cookies
Офіційна документація Next.js стверджує, що ми можемо задавати серверні куки в Middleware, Server Actions та Route Handlers: Cookies in Next.js. І це створює фальшиве відчуття безпеки.
Тут знадобиться додатковий контекст. Для автентифікації/авторизації на Freeversity я використовую окремий опенсорс-сервіс Keycloak. Це чудове та надзвичайно потужне рішення. З ним можна дуже тонко налаштовувати доступи до ресурсів, а також керувати способами автентифікації через купу сторонніх OAuth-провайдерів. Чудо, а не технологія!
Keycloak видає та оновлює велику кількість різноманітних токенів, які містять в собі ідентифікаційну інформацію та інформацію про запитувані дозволи. Саме ці токени дають можливість швидко та без додаткових запитів авторизовувати доступ користувачів до ресурсів. Токени мені потрібні вже на першому ж запиті, тож зберігати я їх вирішив в cookie.
Повертаємось до того, що задавати серверні куки ми можемо лише в middleware, server actions та route handlers.
Server Actions |
Виконуються тільки з завантаженої сторінки у відповідь на дію користувача. Не підходить. |
Middleware |
Виконується перед кожним запитом. В цілому підходить, але відривати авторизаційну логіку так далеко від API не хотілося б. |
Route Handler |
Саме тут нам і потрібна перевірка доступу. Виглядає як те, що треба. |
І саме за такою логікою я почав вибудовувати архітектуру авторизації/автентифікації. Аж раптом виявилось, що перший запит на Route Handler завжди відправляється з Server Component. А в серверних компонентах встановлювати куки не можна.
Так, ми встановили куки в Route Handler, але запит відправляється з серверного компонента, тож до браузера користувача вони не дійдуть. Проблема більш детально описана та обговорена в топіку в репозиторії Next.js та є очікуваною поведінкою фреймворка (з відповідною кількістю негативних реакцій від розробників).
Рішенням стало дублювання роутінгу з авторизаційними запитами в middleware, де куки встановлювати дозволено. Відповідно до кожного окремого роуту. Тобто, у два рази більше коду і наразі це єдиний можливий спосіб додати HttpOnly-куки за першого запиту.
Serverless Functions
В цій секції я зазначу, що мої знання про Cloud-інфраструктуру можуть бути обмеженими, бо в першу чергу я фронтенд-розробник. Тож якщо ви побачите тут фактологічні помилки, чи неправильні висновки — закликаю вас дати мені про це знати в коментарях, щоб я оперативно їх виправив.
За своєю сутністю Vercel Serverless Functions є AWS Lambda-функціями. У світлі цього цікавою особливістю цінової політики Vercel є той факт, що на сайті ми бачимо лише три плани: Hobby (безкоштовний), Pro ($20/місяць) та Custom. Надзвичайно щедрий безкоштовний план переходить у все ще дуже щедрий дешевий план, а далі про порядок цін можна лише здогадуватись.
І це цілком логічно, бо найбільша цінова ефективність Lambda-функцій спостерігається саме за умови невеликих навантажень: Economics of ’Serverless’. А деплоймент Next.js-застосунків поза Vercel — це зовсім окрема історія, яку, однак, варто враховувати перед вибором Next.js для повноцінного комерційного проєкту.
Поза ціновою політикою та загальновідомими проблемами з холодним стартом Serverless Functions, особисто для мене найбільшою складністю стала невідповідність моєї ментальної моделі виконання серверного коду тому, що насправді відбувається в Next.js.
Це не звичний Node.js-сервер. Як я згадував раніше, кожне виконання такої функції має свій окремий контекст і не може зберігати внутрішній стан для різних запитів.
На практиці це, значить, що якщо ви використовуєте на бекенді сторонні з’єднання, і вони потребують автентифікації, ви не зможете ініціалізувати їх один раз перед запуском і зберегти цей стан для обробки всіх подальших реквестів. Ви будете змушені робити ініціалізацію окремо для кожного клієнтського запита.
Тож якщо я використовую вищезгаданий сервіс Keycloak або базу даних Postgres — для кожного запиту ініціалізація з’єднань та запит токенів буде відбуватися наново. Частково вирішити цю проблему можна або витративши додаткові гроші на Redis, або окремим повноцінним сервером для бекенд-API (але це ускладнить використання Server Actions та підтримку міддлверу з авторизаційними куками).
Development experience
Особисто для мене найбільшою проблемою під час розробки стали недостатньо зрозумілі помилки. Особливо в продакшні. Також ці помилки можуть відтворюватися нерегулярно. Ба більше, вони можуть не відтворюватися локально, але крашити продакшн-середовище.
Ситуації, коли все чудово працює до пуша, але валиться з загадковою помилкою після деплою, стали болючою, але звичною частиною процесу розробки на Next.js. І дебаг енвайронментів Vercel приємною справою назвати важко. Тож на рівні вражень, окремих рантаймів зі своїми правилами, де щось може піти не так, в Next.js не п’ять, а ще більше.
До того ж Next.js є настільки роздутим, що TypeScript-лінтер та ESLint не будуть працювати для всього проєкту в дев-режимі (і щось мені підказує, що необхідність білдити код під п’ять різних рантаймів одночасно є однією з причин такої незручності). Тому обов’язково перед кожним пушем в репозиторій перевіряйте, що продакшн-білд проходить без помилок.
Висновок
Чи варта оновлена імплементація загальновідомого Next.js описаних зусиль та незручностей, вирішувати вам для кожного конкретного проєкту. Next.js справді вирішує велику кількість проблем, пов’язаних з SSR, а в багатьох випадках спрощує процес розробки.
Але не дайте фальшивому відчуттю безпеки себе надурити. Це вже не той звичний Next.js, який вважався найбільш обкатаним та надійним рішенням для SSR.
App Router ламає через коліно всі звичні ментальні моделі. Щось з цих нових особливостей виглядає як зручний фокус. Інше — як чорна магія (імпорт куків клієнтського запита в серверному коді все ще викликає в мене відчуття глюку в матриці). А дещо дратує так сильно, що перевести проєкт на інший фреймворк видається не такою вже і складною задачею, порівняно з подальшою розробкою на Next.js.
В усіх трьох випадках причина в тому, що в Next.js майже нічого насправді не є тим, чим намагається здаватися. Рантаймів насправді не три, серверне середовище не є серверним, Fetch API не Fetch API, а клієнтські компоненти не тільки клієнтські. І навіть код, що працює локально, не завжди буде працювати після деплою.
55 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів