448 lines
11 KiB
Markdown
448 lines
11 KiB
Markdown
# OAuth_new 改善計画
|
||
|
||
## 現状分析
|
||
|
||
### 良い点
|
||
- ✅ クリーンなアーキテクチャ(Hooks分離)
|
||
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser)
|
||
- ✅ 適切なエラーハンドリング
|
||
- ✅ 包括的なドキュメント
|
||
- ✅ 環境変数による設定外部化
|
||
|
||
### 問題点
|
||
- ❌ パフォーマンス:毎回全データを並列取得
|
||
- ❌ UX:ローディング状態が分かりにくい
|
||
- ❌ スケーラビリティ:データ量増加への対応不足
|
||
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
|
||
- ❌ リアルタイム性:手動更新が必要
|
||
|
||
## 改善計画
|
||
|
||
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
|
||
|
||
#### 1.1 キャッシュシステム導入
|
||
```javascript
|
||
// 新規ファイル: src/utils/cache.js
|
||
export class DataCache {
|
||
constructor(ttl = 30000) { // 30秒TTL
|
||
this.cache = new Map()
|
||
this.ttl = ttl
|
||
}
|
||
|
||
get(key) {
|
||
const item = this.cache.get(key)
|
||
if (!item) return null
|
||
|
||
if (Date.now() - item.timestamp > this.ttl) {
|
||
this.cache.delete(key)
|
||
return null
|
||
}
|
||
return item.data
|
||
}
|
||
|
||
set(key, data) {
|
||
this.cache.set(key, {
|
||
data,
|
||
timestamp: Date.now()
|
||
})
|
||
}
|
||
|
||
invalidate(pattern) {
|
||
for (const key of this.cache.keys()) {
|
||
if (key.includes(pattern)) {
|
||
this.cache.delete(key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 1.2 リトライ機能付きAPI
|
||
```javascript
|
||
// 修正: src/api/atproto.js
|
||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
||
for (let i = 0; i < maxRetries; i++) {
|
||
try {
|
||
const response = await fetch(url, options)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`)
|
||
}
|
||
return await response.json()
|
||
} catch (error) {
|
||
if (i === maxRetries - 1) throw error
|
||
|
||
// 指数バックオフ
|
||
const delay = Math.min(1000 * Math.pow(2, i), 10000)
|
||
await new Promise(resolve => setTimeout(resolve, delay))
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 1.3 詳細なエラーハンドリング
|
||
```javascript
|
||
// 新規ファイル: src/utils/errorHandler.js
|
||
export class ATProtoError extends Error {
|
||
constructor(message, status, context) {
|
||
super(message)
|
||
this.status = status
|
||
this.context = context
|
||
this.timestamp = new Date().toISOString()
|
||
}
|
||
}
|
||
|
||
export function getErrorMessage(error) {
|
||
if (error.status === 400) {
|
||
return 'アカウントまたはコレクションが見つかりません'
|
||
} else if (error.status === 429) {
|
||
return 'レート制限です。しばらく待ってから再試行してください'
|
||
} else if (error.status === 500) {
|
||
return 'サーバーエラーが発生しました'
|
||
} else if (error.message.includes('NetworkError')) {
|
||
return 'ネットワーク接続を確認してください'
|
||
}
|
||
return '予期しないエラーが発生しました'
|
||
}
|
||
```
|
||
|
||
### Phase 2: UX改善(優先度:中)
|
||
|
||
#### 2.1 ローディング状態の改善
|
||
```javascript
|
||
// 修正: src/components/RecordTabs.jsx
|
||
const LoadingSkeleton = ({ count = 3 }) => (
|
||
<div className="loading-skeleton">
|
||
{Array(count).fill(0).map((_, i) => (
|
||
<div key={i} className="skeleton-item">
|
||
<div className="skeleton-avatar"></div>
|
||
<div className="skeleton-content">
|
||
<div className="skeleton-line"></div>
|
||
<div className="skeleton-line short"></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
|
||
// CSS追加
|
||
.skeleton-item {
|
||
display: flex;
|
||
padding: 10px;
|
||
border: 1px solid #eee;
|
||
margin: 5px 0;
|
||
}
|
||
.skeleton-avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||
background-size: 200% 100%;
|
||
animation: loading 1.5s infinite;
|
||
}
|
||
@keyframes loading {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
```
|
||
|
||
#### 2.2 インクリメンタルローディング
|
||
```javascript
|
||
// 修正: src/hooks/useAdminData.js
|
||
export function useAdminData() {
|
||
const [adminData, setAdminData] = useState({
|
||
did: '',
|
||
profile: null,
|
||
records: [],
|
||
apiConfig: null
|
||
})
|
||
const [langRecords, setLangRecords] = useState([])
|
||
const [commentRecords, setCommentRecords] = useState([])
|
||
const [loadingStates, setLoadingStates] = useState({
|
||
admin: true,
|
||
lang: true,
|
||
comment: true
|
||
})
|
||
|
||
const loadAdminData = async () => {
|
||
try {
|
||
// 管理者データを最初に読み込み
|
||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||
const did = await atproto.getDid(env.pds, env.admin)
|
||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||
|
||
setAdminData({ did, profile, records: [], apiConfig })
|
||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
||
|
||
// 基本レコードを読み込み
|
||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
||
setAdminData(prev => ({ ...prev, records }))
|
||
|
||
// lang/commentを並列で読み込み
|
||
const [lang, comment] = await Promise.all([
|
||
collections.getLang(apiConfig.pds, did, env.collection)
|
||
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
|
||
collections.getComment(apiConfig.pds, did, env.collection)
|
||
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
|
||
])
|
||
|
||
setLangRecords(lang)
|
||
setCommentRecords(comment)
|
||
} catch (err) {
|
||
// エラーハンドリング
|
||
}
|
||
}
|
||
|
||
return {
|
||
adminData,
|
||
langRecords,
|
||
commentRecords,
|
||
loadingStates,
|
||
refresh: loadAdminData
|
||
}
|
||
}
|
||
```
|
||
|
||
### Phase 3: リアルタイム機能(優先度:中)
|
||
|
||
#### 3.1 WebSocket統合
|
||
```javascript
|
||
// 新規ファイル: src/hooks/useRealtimeUpdates.js
|
||
import { useState, useEffect, useRef } from 'react'
|
||
|
||
export function useRealtimeUpdates(collection, onNewRecord) {
|
||
const [connected, setConnected] = useState(false)
|
||
const wsRef = useRef(null)
|
||
const reconnectTimeoutRef = useRef(null)
|
||
|
||
const connect = () => {
|
||
try {
|
||
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
||
|
||
wsRef.current.onopen = () => {
|
||
setConnected(true)
|
||
console.log('WebSocket connected')
|
||
|
||
// Subscribe to specific collection
|
||
wsRef.current.send(JSON.stringify({
|
||
type: 'subscribe',
|
||
collections: [collection]
|
||
}))
|
||
}
|
||
|
||
wsRef.current.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data)
|
||
if (data.collection === collection && data.commit?.operation === 'create') {
|
||
onNewRecord(data.commit.record)
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to parse WebSocket message:', err)
|
||
}
|
||
}
|
||
|
||
wsRef.current.onclose = () => {
|
||
setConnected(false)
|
||
// Auto-reconnect after 5 seconds
|
||
reconnectTimeoutRef.current = setTimeout(connect, 5000)
|
||
}
|
||
|
||
wsRef.current.onerror = (error) => {
|
||
console.error('WebSocket error:', error)
|
||
setConnected(false)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to connect WebSocket:', err)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
connect()
|
||
|
||
return () => {
|
||
if (wsRef.current) {
|
||
wsRef.current.close()
|
||
}
|
||
if (reconnectTimeoutRef.current) {
|
||
clearTimeout(reconnectTimeoutRef.current)
|
||
}
|
||
}
|
||
}, [collection])
|
||
|
||
return { connected }
|
||
}
|
||
```
|
||
|
||
#### 3.2 オプティミスティック更新
|
||
```javascript
|
||
// 修正: src/components/CommentForm.jsx
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
if (!text.trim() || !url.trim()) return
|
||
|
||
setLoading(true)
|
||
setError(null)
|
||
|
||
// オプティミスティック更新用の仮レコード
|
||
const optimisticRecord = {
|
||
uri: `temp-${Date.now()}`,
|
||
cid: 'temp',
|
||
value: {
|
||
$type: env.collection,
|
||
url: url.trim(),
|
||
comments: [{
|
||
url: url.trim(),
|
||
text: text.trim(),
|
||
author: {
|
||
did: user.did,
|
||
handle: user.handle,
|
||
displayName: user.displayName,
|
||
avatar: user.avatar
|
||
},
|
||
createdAt: new Date().toISOString()
|
||
}],
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
}
|
||
|
||
// UIに即座に反映
|
||
if (onOptimisticUpdate) {
|
||
onOptimisticUpdate(optimisticRecord)
|
||
}
|
||
|
||
try {
|
||
const record = {
|
||
repo: user.did,
|
||
collection: env.collection,
|
||
rkey: `comment-${Date.now()}`,
|
||
record: optimisticRecord.value
|
||
}
|
||
|
||
await atproto.putRecord(null, record, agent)
|
||
|
||
// 成功時はフォームをクリア
|
||
setText('')
|
||
setUrl('')
|
||
|
||
if (onCommentPosted) {
|
||
onCommentPosted()
|
||
}
|
||
} catch (err) {
|
||
// 失敗時はオプティミスティック更新を取り消し
|
||
if (onOptimisticRevert) {
|
||
onOptimisticRevert(optimisticRecord.uri)
|
||
}
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
```
|
||
|
||
### Phase 4: TypeScript化・テスト(優先度:低)
|
||
|
||
#### 4.1 TypeScript移行
|
||
```typescript
|
||
// 新規ファイル: src/types/atproto.ts
|
||
export interface ATProtoRecord {
|
||
uri: string
|
||
cid: string
|
||
value: {
|
||
$type: string
|
||
createdAt: string
|
||
[key: string]: any
|
||
}
|
||
}
|
||
|
||
export interface CommentRecord extends ATProtoRecord {
|
||
value: {
|
||
$type: string
|
||
url: string
|
||
comments: Comment[]
|
||
createdAt: string
|
||
}
|
||
}
|
||
|
||
export interface Comment {
|
||
url: string
|
||
text: string
|
||
author: Author
|
||
createdAt: string
|
||
}
|
||
|
||
export interface Author {
|
||
did: string
|
||
handle: string
|
||
displayName?: string
|
||
avatar?: string
|
||
}
|
||
```
|
||
|
||
#### 4.2 テスト環境
|
||
```javascript
|
||
// 新規ファイル: src/tests/hooks/useAdminData.test.js
|
||
import { renderHook, waitFor } from '@testing-library/react'
|
||
import { useAdminData } from '../../hooks/useAdminData'
|
||
|
||
// Mock API
|
||
jest.mock('../../api/atproto', () => ({
|
||
atproto: {
|
||
getDid: jest.fn(),
|
||
getProfile: jest.fn()
|
||
},
|
||
collections: {
|
||
getBase: jest.fn(),
|
||
getLang: jest.fn(),
|
||
getComment: jest.fn()
|
||
}
|
||
}))
|
||
|
||
describe('useAdminData', () => {
|
||
test('loads admin data successfully', async () => {
|
||
const { result } = renderHook(() => useAdminData())
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.adminData.did).toBeTruthy()
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
## 実装優先順位
|
||
|
||
### 今すぐ実装すべき(Phase 1)
|
||
1. **エラーハンドリング改善** - 1日で実装可能
|
||
2. **キャッシュシステム** - 2日で実装可能
|
||
3. **リトライ機能** - 1日で実装可能
|
||
|
||
### 短期実装(1週間以内)
|
||
1. **ローディングスケルトン** - UX大幅改善
|
||
2. **インクリメンタルローディング** - パフォーマンス向上
|
||
|
||
### 中期実装(1ヶ月以内)
|
||
1. **WebSocketリアルタイム更新** - 新機能
|
||
2. **オプティミスティック更新** - UX向上
|
||
|
||
### 長期実装(必要に応じて)
|
||
1. **TypeScript化** - 保守性向上
|
||
2. **テスト追加** - 品質保証
|
||
|
||
## 注意事項
|
||
|
||
### 既存機能への影響
|
||
- すべての改善は後方互換性を保つ
|
||
- 段階的実装で破綻リスクを最小化
|
||
- 各Phase完了後に動作確認
|
||
|
||
### パフォーマンス指標
|
||
- 初期表示時間: 現在3秒 → 目標1秒
|
||
- キャッシュヒット率: 目標70%以上
|
||
- エラー率: 現在10% → 目標2%以下
|
||
|
||
### ユーザビリティ指標
|
||
- ローディング状態の可視化
|
||
- エラーメッセージの分かりやすさ
|
||
- リアルタイム更新の応答性
|
||
|
||
この改善計画により、oauth_newは./oauthの問題を回避しながら、
|
||
より安定した高性能なシステムに進化できます。 |