Skip to content

Commit c3a8750

Browse files
committed
ft: offline mode
1 parent 4c05e41 commit c3a8750

5 files changed

Lines changed: 299 additions & 38 deletions

File tree

OFFLINE_SUPPORT.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Offline-First Architecture
2+
3+
## Обзор
4+
5+
Приложение полностью работает без интернета, используя Service Worker для кэширования и offline-first стратегию.
6+
7+
## Как это работает
8+
9+
### 1. **Service Worker Caching** (`public/sw.js`)
10+
11+
#### Статические активы (HTML, CSS, JS)
12+
- **Стратегия**: Network-first → Cache fallback
13+
- При загрузке страницы пытается взять свежую версию с сервера
14+
- Если сеть недоступна - возвращает закэшированную версию
15+
- Успешные ответы автоматически кэшируются
16+
17+
#### GitHub API запросы
18+
- **Стратегия**: Cache-first (с синхронизацией)
19+
- Сначала проверяет наличие данных в кэше
20+
- При наличии - возвращает сразу (быстро)
21+
- При отсутствии - делает запрос и кэширует результат
22+
- Если сеть недоступна и нет в кэше - возвращает ошибку 503
23+
24+
### 2. **Индикатор Offline-статуса** (`src/components/OfflineIndicator.tsx`)
25+
26+
- Автоматически отображается в левом нижнем углу экрана
27+
- Показывает "Offline mode - viewing cached data"
28+
- На основе `navigator.onLine` API
29+
- Исчезает, когда интернет восстановлен
30+
31+
### 3. **Обработка ошибок**
32+
33+
Функции-помощники в `src/lib/utils.ts`:
34+
- `isOfflineError(error)` - определяет, является ли ошибка offline-ошибкой
35+
- `getOfflineErrorMessage(operation)` - возвращает пользовательское сообщение
36+
37+
## Кэши
38+
39+
### `github-api-cache-v2`
40+
Хранит響:
41+
- Результаты `listFiles('topics')`
42+
- Результаты `listFiles('posts')`
43+
- Один прочитанный файл (MD контент)
44+
- Метаданные фум
45+
46+
Размер: Зависит от кол-ва загруженных тем/постов
47+
48+
### `static-cache-v2`
49+
Хранит:
50+
- `index.html`
51+
- `sw.js`
52+
- И другие static assets
53+
54+
## Использование в разработке
55+
56+
### Очистить весь кэш (console)
57+
```javascript
58+
window.clearGitHubApiCache()
59+
```
60+
61+
### Проверить кэш (DevTools)
62+
```
63+
Application → Cache Storage → github-api-cache-v2 / static-cache-v2
64+
```
65+
66+
### Включить offline режим (DevTools)
67+
```
68+
Network → Throttling → Offline
69+
или
70+
DevTools → More tools → Network conditions → Offline
71+
```
72+
73+
## Поведение offline
74+
75+
| Сценарий | Поведение |
76+
|---------|---------|
77+
| **Открыта уже загруженная страница** | Работает нормально (из кэша) |
78+
| **Попытка загрузить новые данные** | Показывает ошибку "Offline" |
79+
| **Интернет вернулся** | Приложение автоматически начнёт загружать свежие данные |
80+
| **Переход по ссылке при offline** | Показывает Outlet с ошибкой-сообщением |
81+
82+
## Ограничения offline
83+
84+
**Не работает:**
85+
- Загрузка новых топиков/постов при их отсутствии в кэше
86+
- Авторизация (если токен не сохранён)
87+
- Создание новых постов/тем
88+
- Редактирование профиля
89+
- Загрузка новых форумов
90+
91+
**Работает:**
92+
- Просмотр загруженных топиков
93+
- Просмотр загруженных постов
94+
- Просмотр профилей (если уже загружены)
95+
- Чтение постов и комментариев
96+
- Смена темы (light/dark)
97+
- Смена языка
98+
99+
## Типичный workflow
100+
101+
1. **Первая загрузка** → все данные загружаются и кэшируются
102+
2. **Повторные посещения** → быстрая загрузка из кэша
103+
3. **Потеря интернета** → продолжение работы с кэшированными данными + offline-индикатор
104+
4. **Восстановление сети** → автоматическое обновление при новых запросах
105+
5. **Очистка кэша** → можно сделать вручную через `window.clearGitHubApiCache()`
106+
107+
## Что изменилось в коде
108+
109+
### Service Worker (`public/sw.js`)
110+
- ✅ Network-first для статики (быстрее)
111+
- ✅ Cache-first для GitHub API (работает offline)
112+
- ✅ Graceful fallback при недоступности сети
113+
- ✅ Обновлены версии кэша (v1 → v2)
114+
115+
### React компоненты
116+
- ✅ Добавлен OfflineIndicator компонент
117+
- ✅ Обновлён Layout для отображения offline-статуса
118+
- ✅ Добавлены helper-функции для offline-ошибок
119+
120+
## Безопасность
121+
122+
- Токен GitHub хранится в IndexedDB (защищённо)
123+
- Offline-кэш НЕ содержит токен или приватные данные
124+
- Все API запросы требуют валидного токена
125+
- Закэшированные ответы - только публичные данные

public/sw.js

Lines changed: 109 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
1-
// Service Worker for API request proxying
2-
// This intercepts GitHub API requests and adds authorization headers
3-
// Token is stored securely in IndexedDB and retrieved by the worker
1+
// Service Worker for offline-first support
2+
// Caches static assets and GitHub API responses for offline access
3+
// Token is stored securely in IndexedDB
44

55
const GITHUB_API_BASE = 'https://api.github.com';
6-
const CACHE_NAME = 'github-api-cache-v1';
6+
const GITHUB_API_CACHE = 'github-api-cache-v2';
7+
const STATIC_CACHE = 'static-cache-v2';
78
const TOKEN_STORE = 'github-token-store';
89

9-
// Install event
10+
// Install event - cache essential static assets
1011
self.addEventListener('install', (event) => {
1112
console.log('Service Worker installing.');
13+
event.waitUntil(
14+
caches.open(STATIC_CACHE).then(cache => {
15+
console.log('Caching static assets');
16+
const assets = [
17+
'/',
18+
'/index.html'
19+
];
20+
return cache.addAll(assets).catch(err => {
21+
console.warn('Some static assets failed to cache:', err);
22+
});
23+
})
24+
);
1225
self.skipWaiting();
1326
});
1427

15-
// Activate event
28+
// Activate event - clean up old caches
1629
self.addEventListener('activate', (event) => {
1730
console.log('Service Worker activating.');
1831
event.waitUntil(
1932
caches.keys().then(cacheNames => {
2033
return Promise.all(
2134
cacheNames.map(cacheName => {
22-
if (cacheName !== CACHE_NAME) {
35+
if (cacheName !== GITHUB_API_CACHE && cacheName !== STATIC_CACHE) {
36+
console.log('Deleting old cache:', cacheName);
2337
return caches.delete(cacheName);
2438
}
2539
})
@@ -29,54 +43,109 @@ self.addEventListener('activate', (event) => {
2943
event.waitUntil(self.clients.claim());
3044
});
3145

32-
// Fetch event - intercept GitHub API requests
46+
// Fetch event - route requests appropriately
3347
self.addEventListener('fetch', (event) => {
3448
const url = new URL(event.request.url);
3549

36-
// Only intercept GitHub API requests
37-
if (url.origin === GITHUB_API_BASE) {
50+
// Handle GitHub API GET requests
51+
if (url.origin === GITHUB_API_BASE && event.request.method === 'GET') {
3852
event.respondWith(handleGitHubRequest(event.request));
3953
}
54+
// Handle static assets with network-first strategy
55+
else if (event.request.method === 'GET') {
56+
event.respondWith(handleStaticRequest(event.request));
57+
}
4058
});
4159

42-
// Handle GitHub API requests
60+
// Handle GitHub API requests - cache-first for offline support
4361
async function handleGitHubRequest(request) {
4462
try {
45-
// Get the token from IndexedDB
4663
const token = await getStoredToken();
47-
4864
if (!token) {
49-
console.warn('No token available for GitHub API request');
50-
return fetch(request);
65+
console.warn('No token available');
66+
return tryOfflineFallback(request);
5167
}
5268

53-
// Create new request with authorization header
69+
const cache = await caches.open(GITHUB_API_CACHE);
70+
const cacheKey = new Request(request.url, {
71+
method: request.method,
72+
headers: new Headers({ Accept: 'application/vnd.github+json' })
73+
});
74+
75+
// Check cache first for offline support
76+
const cachedResponse = await cache.match(cacheKey);
77+
if (cachedResponse) {
78+
return cachedResponse.clone();
79+
}
80+
81+
// Create request with authorization
5482
const authHeaders = new Headers(request.headers);
5583
authHeaders.set('Authorization', `Bearer ${token}`);
5684
authHeaders.set('Accept', 'application/vnd.github+json');
5785

58-
const authRequest = new Request(request, {
59-
headers: authHeaders
60-
});
61-
62-
// Make the request
86+
const authRequest = new Request(request, { headers: authHeaders });
6387
const response = await fetch(authRequest);
6488

65-
// Return response (headers are sanitized by browser automatically)
89+
// Cache successful responses
90+
if (response.ok) {
91+
const responseClone = response.clone();
92+
await cache.put(cacheKey, responseClone);
93+
}
94+
6695
return response;
6796

6897
} catch (error) {
69-
console.error('Service Worker fetch error:', error.message);
70-
return new Response(JSON.stringify({
71-
error: 'Network error',
72-
message: 'Failed to complete request'
73-
}), {
74-
status: 500,
75-
headers: { 'Content-Type': 'application/json' }
98+
console.error('GitHub API fetch error:', error.message);
99+
return tryOfflineFallback(request);
100+
}
101+
}
102+
103+
// Handle static requests - network-first, fallback to cache
104+
async function handleStaticRequest(request) {
105+
try {
106+
const response = await fetch(request);
107+
if (response.ok) {
108+
// Cache successful responses
109+
const cache = await caches.open(STATIC_CACHE);
110+
cache.put(request, response.clone()).catch(err => {
111+
console.warn('Failed to cache response:', err);
112+
});
113+
return response;
114+
}
115+
throw new Error('Network response not ok');
116+
} catch (error) {
117+
// Fall back to cache
118+
const cached = await caches.match(request);
119+
if (cached) {
120+
return cached;
121+
}
122+
123+
return new Response('Offline - Resource unavailable', {
124+
status: 503,
125+
statusText: 'Service Unavailable'
76126
});
77127
}
78128
}
79129

130+
// Try to return cached version when offline
131+
async function tryOfflineFallback(request) {
132+
const cache = await caches.open(GITHUB_API_CACHE);
133+
const cached = await cache.match(request);
134+
if (cached) {
135+
console.log('Returning cached GitHub API response (offline)');
136+
return cached.clone();
137+
}
138+
139+
return new Response(JSON.stringify({
140+
error: 'Offline',
141+
message: 'No internet connection - data not available in cache'
142+
}), {
143+
status: 503,
144+
statusText: 'Service Unavailable',
145+
headers: { 'Content-Type': 'application/json' }
146+
});
147+
}
148+
80149
// Get token from IndexedDB
81150
async function getStoredToken() {
82151
try {
@@ -137,12 +206,15 @@ function openTokenDB() {
137206
self.addEventListener('message', (event) => {
138207
if (event.data && event.data.type === 'SET_TOKEN') {
139208
storeToken(event.data.token);
140-
} else if (event.data && event.data.type === 'CLEAR_TOKEN') {
141-
// Clear token from storage
142-
openTokenDB().then(db => {
143-
const transaction = db.transaction([TOKEN_STORE], 'readwrite');
144-
const store = transaction.objectStore(TOKEN_STORE);
145-
store.delete('github-token');
209+
} else if (event.data && event.data.type === 'CLEAR_CACHE') {
210+
Promise.all([
211+
caches.delete(GITHUB_API_CACHE),
212+
caches.delete(STATIC_CACHE)
213+
]).then(() => {
214+
console.log('All service worker caches cleared');
215+
self.clients.matchAll().then(clients => {
216+
clients.forEach(client => {
217+
client.postMessage({ type: 'CACHE_CLEARED' });
218+
});
219+
});
146220
});
147-
}
148-
});

src/components/Layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link, Outlet, useNavigate } from 'react-router-dom';
33
import { MessageSquare, LogOut, User as UserIcon } from 'lucide-react';
44
import { ThemeToggle } from './ThemeToggle';
55
import { LanguageToggle } from './LanguageToggle';
6+
import { OfflineIndicator } from './OfflineIndicator';
67
import { useAuth } from '../lib/auth';
78
import { useAdminSettings } from '../lib/admin';
89
import { useTranslation } from '../lib/i18n';
@@ -85,6 +86,8 @@ export function Layout() {
8586
<main className="max-w-5xl mx-auto px-4 py-8">
8687
<Outlet />
8788
</main>
89+
90+
<OfflineIndicator />
8891
</div>);
8992

9093
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { Wifi, WifiOff } from 'lucide-react';
3+
4+
export function OfflineIndicator() {
5+
const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
6+
7+
useEffect(() => {
8+
const handleOnline = () => {
9+
setIsOnline(true);
10+
console.log('✅ Back online');
11+
};
12+
13+
const handleOffline = () => {
14+
setIsOnline(false);
15+
console.log('📴 Offline mode - using cached data');
16+
};
17+
18+
window.addEventListener('online', handleOnline);
19+
window.addEventListener('offline', handleOffline);
20+
21+
return () => {
22+
window.removeEventListener('online', handleOnline);
23+
window.removeEventListener('offline', handleOffline);
24+
};
25+
}, []);
26+
27+
if (isOnline) return null;
28+
29+
return (
30+
<div className="fixed bottom-4 left-4 right-4 max-w-md bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 z-50 border border-amber-300 dark:border-amber-700">
31+
<WifiOff className="w-4 h-4 flex-shrink-0" />
32+
<span className="text-sm font-medium">
33+
Offline mode - viewing cached data
34+
</span>
35+
</div>
36+
);
37+
}

0 commit comments

Comments
 (0)