Vue Router Citadel — структурований middleware layer для Vue Router, якого мені не вистачало
Мої вітання спільнота DOU. Я front-end розробник, з певним досвідом у enterprise та startup fintech проєктах. Стаття буде корисна front-end розробникам, котрі працюють з Vue екосистемою та стикались з проблемами масштабування navigation guards у великих застосунках.
Мета — поділитись досвідом та інструментом для вирішення непростих задач, і залучити спільноту до open-source взаємодії. У статті описані основні концепти та можливості бібліотеки, але без повного дублювання документації. Якщо зацікавить і захочете спробувати — є сторінка документації з детальними поясненнями, прикладами коду та діаграмами.
Проблема
Navigation guards у Vue Router добре працюють у невеликих застосунках, де їх кількість обмежена і логіка проста. Але в проєктах, що масштабуються — особливо в модульних SPA з десятками маршрутів — навігаційна логіка поступово перетворюється на окрему policy-систему.
Зʼявляються перевірки доступу (RBAC), підписок, feature flags, стану акаунта, KYC, MFA, SSO, передзавантаження даних. Кожна з них реалізується як guard — часто незалежно одна від одної, різними розробниками та в різний час. Vue Router 4 прибрав болі next() callback через return-based API, але архітектурні проблеми залишились.
Неявний порядок виконання. Кожен модуль реєструє власний beforeEach, але загальна картина виконання стає непрозорою. Порядок залежить від моменту реєстрації, а не від явного пріоритету. Частина перевірок виконується глобально, частина — на рівні маршрутів, частина — всередині компонентів (beforeRouteEnter, beforeRouteLeave). Взаємодія між ними не завжди очевидна, і єдиної точки, де видно повний navigation chain — не існує.
Відсутність route-scoping. Vue Router не надає вбудованого механізму route-scoped middleware. Зазвичай це реалізується через перевірки to.meta у глобальному beforeEach — що призводить до розростання умов, дублювання перевірок і жорсткого звʼязку між конфігурацією маршруту і реалізацією guard.
Динамічне управління. У модульних застосунках кожен модуль має зареєструвати свої guards при ініціалізації, привʼязати їх до маршрутів і зняти при деактивації — незалежно від інших модулів. Стандартний API дозволяє видаляти beforeEach, але координація цього вручну в масштабованій системі швидко стає крихкою. І якщо guard потребує важких залежностей — немає вбудованого способу завантажити його lazy.
Observability та error handling. Коли навігація блокується або відбувається redirect — часто складно визначити, який саме guard це спричинив. Немає централізованого логування, немає єдиного механізму обробки помилок, немає контролю часу виконання async перевірок.
У міру зростання застосунку navigation guards перестають бути просто «перевірками перед переходом» — вони стають policy-шаром. Але Vue Router не надає інструментів для керування цією policy-системою як єдиним цілим.
Як це виглядає в коді
Приклад нижче використовує next() з часів Vue Router 3 — з return-based API у Vue Router 4 проблеми виглядають так само. Типовий проєкт після року розробки кількома розробниками:
// guards/maintenance.ts — розробник А, січень
router.beforeEach((to, from, next) => {
const isMaintenanceMode = localStorage.getItem('maintenance') === 'true';
if (isMaintenanceMode) {
next(false);
return;
}
next();
});
// guards/auth.ts — розробник Б, лютий
router.beforeEach(async (to, from, next) => {
const isAuthenticated = Boolean(localStorage.getItem('token'));
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'login' });
return;
}
next();
});
// guards/permissions.ts — розробник В, березень
router.beforeEach(async (to, from, next) => {
if (to.meta.roles) {
const user = await fetchUser();
if (!to.meta.roles.includes(user.role)) {
next({ name: 'forbidden' });
return;
}
}
next();
});
// guards/feature-flags.ts — розробник Г, через пів року
router.beforeEach((to, from, next) => {
if (to.meta.feature && !isFeatureEnabled(to.meta.feature)) {
next({ name: 'not-found' });
return;
}
next();
});
Чотири файли, чотири beforeEach, чотири розробники, нуль координації.
Шлях до рішення
Ці проблеми я вирішував на кожному проєкті. На першому зробив багато напрацювань — обгортки над beforeEach, конвенції для файлів, порядок виконання. На наступних проєктах переносив і доробляв. За 4 роки і 4 великих проєкти — fintech-платформи з
Шукав і дивився існуючі бібліотеки на npm — переглядав API, приклади використання, сорс-код. Не знайшов middleware-бібліотеку, яка була б мінімальною, декларативною та масштабованою одночасно. Більшість вирішують тільки route-scoped middleware і мають спільну проблему — привʼязують референсне посилання на handler прямо до конфігурації роуту через route.meta. Це створює жорсткий звʼязок між визначенням роуту і конкретною реалізацією guard. Немає глобальних guards, немає пріоритетів, немає динамічного управління у runtime. Не кажучи вже про lazy-loading важких handlers або code splitting для guards. Детальне порівняння з альтернативами — на сторінці документації Comparison.
Можна, звісно, написати свій middleware-шар на router.beforeEach і route.meta — це буквально 20 рядків коду. А потім додати обробку помилок. Потім пріоритети. Потім scopes. Потім логування. Потім типізацію. І не встигнеш оглянутися — написав свою бібліотеку. До цього я і дійшов.
Коли почав новий пет-проєкт — вирішив нарешті оформити все в окрему бібліотеку. Доопрацював накопичені підходи, додав те чого не вистачало, зібрав разом з повноцінною документацією та тестами.
Як я вирішив основні недоліки вже існуючих бібліотек — до роутів додається тільки імʼя middleware, а самі middleware реєструються централізовано. Роут знає що йому потрібен 'billing:premium', але не знає і не повинен знати де і як цей outpost реалізований. Це розвʼязує руки для динамічного управління і як бонус — дало можливість зробити type-safe naming через declaration merging. TypeScript підказує доступні імена outposts в IDE і валідує їх на етапі компіляції.
Vue Router Citadel
Vue Router Citadel — navigation policy management layer для Vue Router 4 та 5. Не заміна router guards, а тонка абстракція поверх існуючих beforeEach / beforeResolve / afterEach — яка додає структуру, коли guards перетворюються на policy-систему. Розрахована на застосунки де стандартних guards вже не вистачає:
- Access control — RBAC-системи, permission gates, admin areas, multi-tenant з валідацією підписок та feature flags
- Модульна архітектура — type-safe декларації по модулях, динамічне управління outposts у runtime
- Складна навігаційна логіка — SSO, MFA, session refresh, data preloading
Vue Router вже використовує термін guards — охоронці маршрутів. Хотілося підібрати концепт, який органічно продовжує цю ідею. Так зʼявилась ціла низка назв та їх звʼязків:
- Citadel (цитадель) — захисний шар над роутером, точка входу і центр управління
- Outposts (аванпости) — окремі guards-чекпоінти вздовж маршруту, кожен зі своєю зоною відповідальності
- Verdicts (вердикти) — рішення кожного outpost:
ALLOW(пропустити),BLOCK(заблокувати) або redirect (перенаправити) - Scope (скоуп) — зона дії outpost:
global(на кожну навігацію) абоroute(тільки для конкретних сторінок) - Deploy / Abandon — розгортання та згортання outposts
- Assign / Revoke — привʼязка та відвʼязка outposts від конкретних роутів у runtime для
routescope outposts - Patrol — послідовний обхід outposts під час навігації: кожен outpost перевіряється по черзі, поки один не зупинить patrol своїм verdict
Назви можуть здатись незвичними, але вони дають зрозумілу ментальну модель: цитадель захищає застосунок, аванпости стоять на маршрутах, кожен виносить свій вердикт.
Ось як виглядає повний setup:
import { createRouter, createWebHistory } from 'vue-router';
import { createNavigationCitadel } from 'vue-router-citadel';
import { createApp } from 'vue';
import App from './App.vue';
// 1. Створити router та routes
const routes = [
{
path: '/',
name: 'home',
component: () => import('./pages/Home.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('./pages/Login.vue'),
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('./pages/Dashboard.vue'),
meta: { requiresAuth: true },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 2. Створити citadel та outposts
const outposts = [
{
name: 'maintenance',
priority: 1,
handler: ({ verdicts }) => {
const isMaintenanceMode = localStorage.getItem('maintenance') === 'true';
if (isMaintenanceMode) {
return verdicts.BLOCK;
}
return verdicts.ALLOW;
},
},
{
name: 'auth',
priority: 10,
handler: ({ verdicts, to }) => {
const isAuthenticated = Boolean(localStorage.getItem('token'));
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return verdicts.ALLOW;
},
},
];
const citadel = createNavigationCitadel(router, {
outposts,
});
// 3. Підключити як Vue plugin
const app = createApp(App);
app.use(router);
app.use(citadel);
app.mount('#app');
Кожен outpost — це обʼєкт з конфігурацією: name, priority, scope, handler. Handler повертає verdict. Порядок виконання явний через priority. Все в одному місці, але кожен outpost — окрема одиниця зі своєю зоною відповідальності.
Але головне — це не тільки декларативний setup. Outposts можна додавати, прибирати та перепривʼязувати до роутів у runtime — саме те, чого потребує modular monolith архітектура. Кожен модуль реєструє свої outposts при ініціалізації і прибирає при деактивації — незалежно від інших модулів, через єдиний API:
// modules/billing/index.ts
import { NavigationOutpostScopes, type NavigationCitadelAPI } from 'vue-router-citadel';
import { premiumHandler, trialHandler } from './outposts';
// Рекомендована конвенція: 'module:action' — уникає колізій імен між модулями
const outpostNames = ['billing:premium', 'billing:trial-expired'] as const;
export function registerBillingModule(citadel: NavigationCitadelAPI) {
// Реєстрація outposts
citadel.deployOutpost([
{
scope: NavigationOutpostScopes.ROUTE,
name: 'billing:premium',
handler: premiumHandler,
},
{
scope: NavigationOutpostScopes.ROUTE,
name: 'billing:trial-expired',
handler: trialHandler,
},
]);
// Привʼязати outposts до роутів
citadel.assignOutpostToRoute('billing-settings', ['billing:premium']);
citadel.assignOutpostToRoute('billing-plans', ['billing:trial-expired']);
}
export function unregisterBillingModule(citadel: NavigationCitadelAPI) {
// Відвʼязати від роутів
citadel.revokeOutpostFromRoute('billing-settings', ['billing:premium']);
citadel.revokeOutpostFromRoute('billing-plans', ['billing:trial-expired']);
// Прибрати outposts
citadel.abandonOutpost(NavigationOutpostScopes.ROUTE, [...outpostNames]);
}
А для важких handlers є lazy outposts — модуль завантажується через dynamic import при першій навігації і кешується:
citadel.deployOutpost({
name: 'heavy-validation',
lazy: true,
handler: () => import('./outposts/heavy-validation'),
});
Handler тягне свої залежності тільки коли він реально потрібен — не при старті застосунку.
Outposts з production-патернів
Наведені вище глобальні outposts (maintenance, auth) — лише частина картини. На реальних проєктах основна складність — у route-scoped outposts, які працюють тільки на привʼязаних сторінках. Ось кілька патернів, абстрагованих з fintech production проєктів:
// RBAC — перевірка ролі через route meta
citadel.deployOutpost({
scope: 'route',
name: 'role-access',
priority: 20,
handler: ({ verdicts, to }) => {
const user = useAuth().user();
if (user.role !== to.meta.role) {
return { name: 'forbidden' };
}
return verdicts.ALLOW;
},
});
// Onboarding — redirect якщо реєстрація не завершена
citadel.deployOutpost({
scope: 'route',
name: 'onboarding-complete',
handler: ({ verdicts }) => {
if (!useAuth().user().completedRegistration) {
return { name: 'onboarding' };
}
return verdicts.ALLOW;
},
});
// Payment validation — перевірка підтримки платіжного методу
citadel.deployOutpost({
scope: 'route',
name: 'payment-supported',
handler: ({ verdicts, to }) => {
const settings = useTransactionSettings();
if (!settings.hasPaymentMethod(to.meta.paymentMethod)) {
return to.meta.redirect ?? { name: 'dashboard' };
}
return verdicts.ALLOW;
},
});
// Account completeness — async перевірка з завантаженням даних зі store
citadel.deployOutpost({
scope: 'route',
name: 'account-setup',
handler: async ({ verdicts }) => {
const accountStore = useAccountStore();
await accountStore.loadChecks();
if (accountStore.isBlocked) {
return { name: 'account-completeness' };
}
return verdicts.ALLOW;
},
});
Роути посилаються на outposts за іменем — не знаючи деталей реалізації:
const routes = [
{
path: '/admin',
name: 'admin',
component: () => import('./pages/Admin.vue'),
meta: { outposts: ['role-access'], role: 'admin' },
},
{
path: '/account',
name: 'account',
component: () => import('./pages/Account.vue'),
meta: { outposts: ['onboarding-complete', 'account-setup'] },
},
{
path: '/payment/card',
name: 'payment-card',
component: () => import('./pages/PaymentCard.vue'),
meta: {
outposts: ['payment-supported'],
paymentMethod: 'card',
redirect: { name: 'payment-methods' },
},
},
];
Sync і async handlers, meta-driven конфігурація, fallback redirects через meta.redirect — патерни, які повторюються у кожному великому проєкті.
Ключові можливості
Verdict system
Замість next() з його варіаціями — єдиний механізм повернення результату з handler:
verdicts.ALLOW— навігація продовжуєтьсяverdicts.BLOCK— навігація скасовується{ name: 'login' }— redirect на іменований роут{ path: '/error' }— redirect на шлях'/path'— redirect (рядок)throw Error— перехоплюєтьсяonErrorhandler
Redirect-маршрути валідуються через router — якщо вказаний роут не існує, citadel кине помилку замість мовчазного redirect в нікуди. Чистий control flow — handler це функція яка приймає контекст і повертає verdict. Легко читати, легко тестувати.
Outpost scopes
Два scope визначають коли outpost обробляється:
- Global — виконується на кожну навігацію. Auth, maintenance, analytics.
- Route — активується тільки коли привʼязаний до роуту через
meta.outposts.
Обробка йде послідовно: спочатку всі global outposts за пріоритетом, потім route outposts за пріоритетом. Якщо будь-який повертає BLOCK або redirect — навігація зупиняється, наступні outposts не виконуються.
При вкладених роутах outposts збираються з усіх matched routes (батьківський + дочірні) і автоматично дедуплікуються. Дублікати видаляються з попередженням у консоль — краще розміщувати спільні outposts тільки на батьківському роуті:
const routes = [
{
path: '/account',
component: AccountLayout,
meta: { outposts: ['auth'] },
children: [
{
path: 'billing',
name: 'account-billing',
component: Billing,
meta: { outposts: ['verified', 'premium'] }, // auth + verified + premium
},
],
},
];
Priority-based execution
Числовий пріоритет визначає порядок виконання outposts в межах кожного scope. Менше значення — раніше виконання. Default — 100.
citadel.deployOutpost([
{ name: 'analytics', handler: analyticsHandler, priority: 200 },
{ name: 'auth', handler: authHandler, priority: 10 },
{ name: 'permissions', handler: permHandler, priority: 50 },
]);
// Порядок виконання: auth (10) → permissions (50) → analytics (200)
Сортування відбувається при deploy, а не під час навігації — runtime overhead нульовий.
Navigation hooks
Outpost може працювати на будь-якому етапі навігаційного циклу Vue Router:
BEFORE_EACH— перед навігацією, може блокувати (auth, permissions)BEFORE_RESOLVE— після async компонентів, може блокувати (валідація даних)AFTER_EACH— після навігації, не блокує (analytics, logging)
За замовчуванням outpost використовує beforeEach. Один outpost може обробляти кілька hooks:
import { NavigationHooks } from 'vue-router-citadel';
citadel.deployOutpost({
name: 'admin-only',
hooks: [NavigationHooks.BEFORE_RESOLVE, NavigationHooks.AFTER_EACH],
handler: ({ verdicts, hook }) => {
switch (hook) {
case NavigationHooks.BEFORE_RESOLVE: {
// Перевірка ролі
return verdicts.ALLOW;
}
case NavigationHooks.AFTER_EACH: {
// Логування відвідування
return verdicts.ALLOW;
}
default: {
return verdicts.ALLOW;
}
}
},
});
Timeout control
Глобальний таймаут встановлюється через defaultTimeout. Кожен outpost може перевизначити його або вимкнути:
const citadel = createNavigationCitadel(router, {
defaultTimeout: 5000,
onTimeout: (outpostName, ctx) => {
return { name: 'error' };
},
});
// Per-outpost override: 30 секунд замість дефолтних 5
citadel.deployOutpost({
name: 'data-loader',
timeout: 30000,
handler: async ({ verdicts }) => {
await loadHeavyData();
return verdicts.ALLOW;
},
});
Outpost без явного timeout використовує defaultTimeout. Якщо обидва не задані — таймауту немає. Значення 0 вимикає таймаут явно — корисно для afterEach outposts, де таймаут не має сенсу (вони і так не блокують навігацію). Для lazy outposts таймаут рахується тільки для виконання handler — завантаження модуля не обмежується.
Error handling
Централізована обробка помилок через onError та onTimeout. Якщо handler кидає помилку — citadel перехоплює її і передає в onError, який повертає verdict (redirect, BLOCK, або навіть ALLOW):
const citadel = createNavigationCitadel(router, {
onError: (error, ctx) => {
console.error('Navigation error:', error);
return { name: 'error', query: { message: error.message } };
},
});
Для onError є обробник за замовчуванням — помилка логується і навігація блокується. Помилки в afterEach outposts логуються, але не впливають на навігацію — сторінка вже відрендерена.
Type-safe outpost names
Declaration merging для autocomplete та compile-time валідації імен outposts:
// src/outposts.d.ts
declare module 'vue-router-citadel' {
interface GlobalOutpostRegistry {
auth: true;
maintenance: true;
}
interface RouteOutpostRegistry {
'admin-only': true;
'premium': true;
}
}
Після цього TypeScript валідує імена скрізь — в deployOutpost, abandonOutpost, route.meta.outposts:
citadel.abandonOutpost('global', 'auth'); // ✓
citadel.abandonOutpost('global', 'admin-only'); // ✗ Error: not a global outpost
Якщо реєстри не розширені — імена залишаються string, type-safety опціональна.
Vue DevTools
Custom inspector для моніторингу outposts у реальному часі. Включається автоматично в development при app.use(citadel).
Показує дерево outposts по scope, пріоритети, hooks, таймаути. Секція «Route Assignments» показує які роути мають привʼязані outposts через meta.outposts. Секція «Current Route» показує які саме outposts виконаються на поточній сторінці — в тому порядку, в якому вони пройдуть patrol. Оновлюється автоматично при навігації. Панель налаштувань дозволяє перемикати рівень логування і debug-режим прямо з DevTools.
DevTools підключається через dynamic import — якщо вимкнений, його код не потрапляє в production-бандл.
Observability
Коли navigation заблокована — потрібно швидко зрозуміти чому. Citadel логує весь chain execution: старт навігації, кожен outpost по черзі, і який саме outpost зупинив patrol:
🔵 [🏰 NavigationCitadel] beforeEach: / → /dashboard (3 outposts) 🔵 [🏰 NavigationCitadel] Processing outpost 1/3: "maintenance" [beforeEach] 🔵 [🏰 NavigationCitadel] Processing outpost 2/3: "auth" [beforeEach] 🟡 [🏰 NavigationCitadel] Patrol stopped by outpost "auth": redirect → /login
Логування вмикається через log: true (за замовчуванням увімкнено в development). Критичні події — помилки, таймаути, невалідні роути — логуються завжди, незалежно від налаштувань. Рівень логування та debug-режим також можна перемикати прямо з Vue DevTools панелі — без зміни коду та перезавантаження.
Для глибшого дебагу є режим debug: true — він активує іменовані debug points на ключових етапах обробки: navigation-start, outpost-enter, outpost-block, outpost-timeout, error-catch. Кожен debug point викликає debugger; — можна зупинити виконання і подивитись стан прямо в браузері:
const citadel = createNavigationCitadel(router, {
debug: true,
// Custom debug handler замість стандартного debugger;
debugHandler: (pointName) => {
console.trace(`Debug point: ${pointName}`);
debugger;
},
});
І logger, і debugHandler замінюються на кастомні реалізації — це дає можливість інтегрувати citadel з будь-яким observability stack (Datadog, Sentry, власний backend):
const citadel = createNavigationCitadel(router, {
logger: {
info: (...args) => apm.log('info', ...args),
warn: (...args) => apm.log('warn', ...args),
error: (...args) => apm.captureError(...args),
debug: (...args) => apm.log('debug', ...args),
},
});
Разом з централізованим error handling (onError, onTimeout) та Vue DevTools inspector — це дає повну картину того, що відбувається в navigation layer.
Bundle size & compatibility
Core bundle — ≤ 4 KB (minified + brotli). DevTools integration не входить і видаляється через tree-shaking у production. ESM-first з CJS fallback. TypeScript strict mode, сумісність з Vue Router 4 та 5.
Trade-offs
Чесно про обмеження:
- Додатковий abstraction layer — це ще один шар між вашим кодом і Vue Router. Для проєкту з
2-3 guards — overkill. - Learning curve — naming (citadel, outpost, verdict, patrol) потребує звикання. Метафора допомагає після першого дня, але спочатку може здатись незвичною.
- Не для кожного проєкту — якщо guards не ростуть і не змінюються — стандартний
beforeEachпростіший і прямолінійніший.
Кілька питань, які можуть виникнути:
- Performance overhead — сортування outposts відбувається при deploy (один раз), не під час навігації. Runtime — це виклик функцій по масиву з O(1) lookup в registry. Overhead фактично нульовий.
- Testability — outpost handler це функція яка приймає контекст і повертає verdict. Тестується як будь-яка чиста функція — без router, без компонентів, без DOM.
- SSR — бібліотека працює в серверному середовищі. Custom logger дозволяє замінити console на серверний логер (Pino, Bunyan). DevTools автоматично вимикається поза браузером.
Підсумок
Це не срібна куля — для простого проєкту з парою navigation guards стандартного підходу вистачає з головою. Але якщо guards ростуть разом з проєктом — структура рятує.
Якщо навігаційна логіка у вашому застосунку стала policy-системою — можливо, час відокремити її від router-конфігурації.
npm install vue-router-citadel
🏗️ GitHub 📦 npm 📖 Documentation
Place guards at the gates. Outposts along the way.
P.S. Чи були у вас схожі проблеми, чи стаття виявилась корисною, чи описані рішення можуть вам допомогти — пишіть у коментарях. Цікаво почути ваш досвід.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів