Middle: питання на співбесіді з теми «Браузерні API й мережа»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Браузер застосовує політику одного джерела (same-origin policy): JavaScript зі сторінки https://app.example.com не може читати відповіді від https://api.other.com. Джерело (origin) - це схема + домен + порт: http і https, example.com і api.example.com, порти 3000 і 8000 - різні джерела.
Навіщо: інакше будь-який сайт, який ви відкрили, міг би від вашого імені (з вашими cookies) читати пошту, банківський кабінет чи адмінку.
CORS (Cross-Origin Resource Sharing) - спосіб, яким сервер дозволяє певним джерелам читати свої відповіді, через заголовки:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Попередній запит (preflight). Для «непростих» запитів браузер спершу надсилає OPTIONS, щоб запитати дозволу:
- методи, крім
GET,HEAD,POST; - власні заголовки (
Authorization,X-Requested-With); Content-Type: application/json(простими вважаються лише формові типи йtext/plain).
OPTIONS /api/orders
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
Сервер відповідає, що дозволено (Access-Control-Allow-Methods, -Headers, -Max-Age для кешування дозволу). Лише після цього йде справжній запит.
Що важливо розуміти:
- CORS виконує браузер, а не сервер.
curl, Postman і бекенд-код не мають жодних обмежень. CORS - не захист API від чужих клієнтів; - простий запит доходить до сервера і виконується - браузер лише не дає JavaScript прочитати відповідь. Тому CORS не замінює захист від CSRF;
- з cookies (
credentials: 'include') забороненоAccess-Control-Allow-Origin: *- потрібне конкретне джерело іAccess-Control-Allow-Credentials: true; - помилка CORS у консолі часто маскує іншу: сервер повернув 500 чи 404 без CORS-заголовків, і браузер повідомляє про CORS, а не про справжню причину. Варто дивитися запит у вкладці Network.
У Laravel CORS обробляє вбудований middleware HandleCors з налаштуваннями в config/cors.php (публікується php artisan config:publish cors):
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['https://app.example.com'],
'supports_credentials' => true,
Як обійтися без CORS: розмістити фронтенд і API на одному джерелі (Laravel віддає і сторінки, і /api), або проксувати запити через сервер розробки Vite (server.proxy). Тоді запити same-origin, і CORS не потрібен.
History API дає змогу змінювати адресу в рядку браузера і записи в історії переходів без завантаження нової сторінки. На ньому побудовані маршрутизатори SPA (Vue Router, React Router), wire:navigate у Livewire, фільтри каталогів, що відображаються в URL.
pushState - додати новий запис в історію:
history.pushState({ page: 2 }, '', '/jobs?page=2');
Адреса змінилася, кнопка «Назад» поверне на попередню, але запиту на сервер не було і сторінка не перезавантажилася. Що показати - вирішує ваш код.
replaceState - замінити поточний запис, не додаючи нового. Для змін, які не повинні засмічувати історію: введення в поле пошуку, сортування, позиція прокрутки.
popstate - подія, коли користувач переходить по історії кнопками «Назад»/«Вперед»:
window.addEventListener('popstate', (event) => {
renderPage(event.state?.page ?? 1); // відновити стан для цього запису
});
Важливо: pushState і replaceState не викликають popstate. Подія спрацьовує лише при навігації по історії. Тому після pushState оновлювати інтерфейс треба самостійно.
Об'єкт стану (перший аргумент) зберігається разом із записом історії і повертається в event.state. Він серіалізується (як structuredClone), має обмеження розміру - туди кладуть невеликі дані (номер сторінки, id), а не весь список товарів.
Що треба зробити серверу: якщо користувач оновить сторінку чи відкриє посилання /jobs?page=2 напряму, запит піде на сервер. Сервер мусить уміти віддати цю сторінку - інакше 404. Для SPA це «fallback» на index.html, у Laravel - звичайний маршрут, що рендерить сторінку з відповідним станом.
Типові вимоги до SPA-навігації, які легко забути:
- заголовок сторінки (
document.title) - змінюється вручну; - прокрутка: при переході вперед - на початок, при «Назад» - туди, де користувач був. Браузерне
history.scrollRestorationінколи доводиться вимикати й керувати самостійно; - фокус і доступність: зчитувачі екрана не знають, що «сторінка змінилася», - фокус переводять на заголовок нового вмісту;
- аналітика: перегляд сторінки треба відправляти вручну.
Navigation API - новіший інтерфейс із єдиною подією navigate для всіх переходів (посилання, форми, кнопки історії) і перехопленням через event.intercept(). Він простіший для маршрутизаторів, але підтримується ще не всіма браузерами.
Обидві технології дають змогу серверу надсилати дані в браузер без запиту від клієнта. Різниця - у напрямку, протоколі й складності.
Server-Sent Events (SSE) - однобічний потік від сервера до клієнта через звичайне HTTP-з'єднання:
const source = new EventSource('/orders/42/status');
source.addEventListener('status', (event) => {
const { status } = JSON.parse(event.data);
updateBadge(status);
});
source.onerror = () => { /* браузер сам перепідключиться */ };
Сервер відповідає з Content-Type: text/event-stream і пише рядки:
event: status
id: 17
data: {"status":"shipped"}
- автоматичне перепідключення вбудоване; при перепідключенні браузер надсилає
Last-Event-ID, і сервер може дослати пропущене; - працює через звичайний HTTP: проксі, балансувальники, автентифікація cookies - як у будь-якого запиту;
- лише текст, лише від сервера;
- на HTTP/1.1 браузер обмежує ~6 з'єднань на домен - кілька вкладок з SSE можуть їх вичерпати. На HTTP/2 і HTTP/3 обмеження немає.
WebSocket - двобічне постійне з'єднання з власним протоколом:
const socket = new WebSocket('wss://example.com/ws');
socket.onmessage = (event) => render(JSON.parse(event.data));
socket.send(JSON.stringify({ type: 'typing', chat: 7 }));
- обидві сторони надсилають повідомлення будь-коли, з мінімальними накладними витратами;
- текст і бінарні дані;
- перепідключення, «серцебиття», підписки на канали - пишете самі або беруть бібліотеку;
- потрібен сервер, що тримає тисячі постійних з'єднань, і налаштовані проксі.
Коли що:
| Задача | Вибір |
|---|---|
| статус замовлення, сповіщення, прогрес задачі, стрічка подій | SSE |
| потокова відповідь LLM («друкується» по словах) | SSE / потоковий fetch |
| чат, спільне редагування, ігри, присутність користувачів | WebSocket |
| оновлення раз на хвилину | звичайне опитування - найпростіше |
У Laravel-екосистемі:
- WebSocket - Laravel Reverb (свій сервер) чи Pusher, на клієнті Laravel Echo; трансляція подій через
ShouldBroadcast; - SSE -
response()->eventStream()у контролері; Livewire маєwire:streamдля потокового оновлення частини компонента.
Головна перевага SSE - простота: не потрібен окремий сервер і протокол, це звичайний маршрут Laravel. Але кожен відкритий потік тримає процес PHP-FPM зайнятим - під багато одночасних клієнтів потрібні Octane чи окремий сервіс.
Опція credentials визначає, чи надсилає fetch cookies і чи приймає їх з відповіді:
'same-origin'(за замовчуванням) - лише для запитів на той самий домен;'include'- і для інших доменів (потрібна відповідна CORS-конфігурація сервера);'omit'- ніколи.
Для звичайного Laravel-застосунку, де сторінки й запити з одного домену, сесійна cookie надсилається автоматично.
CSRF-захист у Laravel перевіряє, що запит, який змінює дані (POST, PUT, PATCH, DELETE), справді надіслано з вашого сайту. Способи передати токен з JavaScript:
1. Заголовок X-CSRF-TOKEN з мета-тегу:
<meta name="csrf-token" content="{{ csrf_token() }}">
await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(order),
});
2. Заголовок X-XSRF-TOKEN з cookie. Laravel ставить cookie XSRF-TOKEN (зашифрований токен, доступний JavaScript). Axios автоматично читає її і додає заголовок для запитів на той самий домен - тому в Laravel-проєктах з axios про CSRF зазвичай не думають. З fetch це треба робити самому.
3. Поле _token у тілі запиту - якщо відправляється FormData з форми, де є @csrf.
Laravel 13 перевіряє ще й джерело запиту. Middleware PreventRequestForgery пропускає запит без токена, якщо браузер надіслав заголовок Sec-Fetch-Site: same-origin. Сучасні браузери додають його самі, а підробити з іншого сайту неможливо. Токен лишається запасним механізмом для старих браузерів і запитів без цього заголовка.
Помилка 419 (Page Expired / CSRF token mismatch) - токен не передано, він застарів (сесія закінчилася, поки сторінка була відкрита) або сесійна cookie не дійшла. Для довго відкритих сторінок варто обробляти 419: оновити токен чи запропонувати перезавантажити сторінку.
SPA на іншому піддомені зі Sanctum:
await fetch('https://api.example.com/sanctum/csrf-cookie', { credentials: 'include' });
await fetch('https://api.example.com/api/orders', {
method: 'POST',
credentials: 'include',
headers: { 'X-XSRF-TOKEN': decodeURIComponent(getCookie('XSRF-TOKEN')), Accept: 'application/json' },
body: JSON.stringify(order),
});
Потрібні також supports_credentials у config/cors.php, домен SPA у SANCTUM_STATEFUL_DOMAINS і спільний домен сесійних cookies.
Без Accept: application/json Laravel на помилку валідації відповість редиректом, а не JSON з помилками.
Intl - вбудований у браузер і Node.js API інтернаціоналізації. Він знає правила форматування для сотень мов, тож не потрібно писати власні функції чи підключати важкі бібліотеки.
Числа й гроші:
new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }).format(1234567.891);
// "1 234 567,89 ₴"
new Intl.NumberFormat('uk-UA', { notation: 'compact' }).format(1500000);
// "1,5 млн"
new Intl.NumberFormat('uk-UA', { style: 'percent', maximumFractionDigits: 1 }).format(0.1234);
Українські правила - кома в дробах і нерозривний пробіл між розрядами - застосовуються автоматично.
Дати:
new Intl.DateTimeFormat('uk-UA', { dateStyle: 'long', timeZone: 'Europe/Kyiv' })
.format(new Date('2026-10-04T10:00:00Z'));
// "4 жовтня 2026 р."
timeZone важливий: без нього дата форматується в поясі браузера користувача.
Відносний час:
const rtf = new Intl.RelativeTimeFormat('uk', { numeric: 'auto' });
rtf.format(-1, 'day'); // "учора"
rtf.format(3, 'hour'); // "через 3 години"
rtf.format(-5, 'minute'); // "5 хвилин тому"
Множина - найскладніше для української: три форми плюс дроби.
const rules = new Intl.PluralRules('uk');
rules.select(1); // "one" -> 1 коментар, 21 коментар
rules.select(2); // "few" -> 2 коментарі, 22 коментарі
rules.select(5); // "many" -> 5 коментарів, 11 коментарів
rules.select(1.5); // "other" -> 1,5 коментаря
const forms = { one: 'коментар', few: 'коментарі', many: 'коментарів', other: 'коментаря' };
const label = (n) => `${n} ${forms[rules.select(n)]}`;
Ручне правило «якщо 1 - однина, інакше множина» дає «21 коментарів» і «2 коментарів».
Сортування рядків:
['їжак', 'ґанок', 'яблуко', 'єнот'].sort();
// ["яблуко", "єнот", "їжак", "ґанок"] - за кодами символів
['їжак', 'ґанок', 'яблуко', 'єнот'].sort(new Intl.Collator('uk').compare);
// ["ґанок", "єнот", "їжак", "яблуко"] - за абеткою
Продуктивність: створення форматера відносно дороге - для списків створюйте його один раз і використовуйте format() повторно.
Узгодженість з бекендом: PHP має ті самі правила ICU через розширення intl (NumberFormatter, Number::currency() у Laravel), тож формати на сервері й у браузері збігаються.