@meeovi/social 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/index.ts +8 -0
- package/package.json +2 -1
- package/src/cache.ts +44 -0
- package/src/config.ts +8 -17
- package/src/errors.ts +16 -0
- package/src/metrics.ts +52 -0
- package/src/providers/atproto.ts +75 -0
- package/src/providers/mastodon.ts +45 -0
- package/src/rateLimit.ts +41 -0
- package/src/registry.ts +13 -0
- package/src/retry.ts +40 -0
- package/src/swr.ts +22 -0
- package/src/types.ts +28 -0
- package/src/useSocial.ts +25 -0
- package/src/utils.ts +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
# 📦 `@meeovi/social` — README.md
|
|
5
|
+
*(This one is more advanced because of caching, SWR, retry, metrics, etc.)*
|
|
6
|
+
|
|
7
|
+
```md
|
|
8
|
+
# @meeovi/social
|
|
9
|
+
|
|
10
|
+
A powerful, backend‑agnostic social integration layer for Meeovi.
|
|
11
|
+
Supports ATProto (Bluesky), Mastodon, WordPress/BuddyPress, and any custom social backend.
|
|
12
|
+
|
|
13
|
+
## ✨ Features
|
|
14
|
+
|
|
15
|
+
- Pluggable social providers
|
|
16
|
+
- Unified `useSocial()` composable
|
|
17
|
+
- Unified activity feed abstraction
|
|
18
|
+
- Caching (TTL‑based)
|
|
19
|
+
- Stale‑While‑Revalidate (SWR) background refresh
|
|
20
|
+
- Retry + exponential backoff
|
|
21
|
+
- Provider‑specific rate‑limit strategies
|
|
22
|
+
- Analytics hooks (per‑provider metrics)
|
|
23
|
+
- Unified error model
|
|
24
|
+
|
|
25
|
+
## 📦 Installation
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install @meeovi/social
|
|
29
|
+
|
|
30
|
+
🧩 Usage
|
|
31
|
+
|
|
32
|
+
import { useSocial } from '@meeovi/social'
|
|
33
|
+
|
|
34
|
+
const { getActivityFeed } = useSocial()
|
|
35
|
+
|
|
36
|
+
const feed = await getActivityFeed('sebastian')
|
|
37
|
+
🔌 Providers
|
|
38
|
+
|
|
39
|
+
export interface SocialProvider {
|
|
40
|
+
getProfile(handle: string): Promise<SocialProfile>
|
|
41
|
+
listPosts(handle: string): Promise<SocialPost[]>
|
|
42
|
+
createPost?(content: string): Promise<SocialPost>
|
|
43
|
+
}
|
|
44
|
+
Register:
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
registerSocialProvider('mastodon', MastodonProvider)
|
|
48
|
+
🧠 Advanced Features
|
|
49
|
+
Caching
|
|
50
|
+
|
|
51
|
+
cacheKey: `atproto:posts:${handle}`,
|
|
52
|
+
ttlMs: 30000
|
|
53
|
+
SWR (Stale‑While‑Revalidate)
|
|
54
|
+
|
|
55
|
+
swr: true
|
|
56
|
+
Retry + Backoff
|
|
57
|
+
|
|
58
|
+
retry: true
|
|
59
|
+
Provider‑Specific Rate Limits
|
|
60
|
+
|
|
61
|
+
setRateLimit('mastodon', { maxRequests: 10, windowMs: 60000 })
|
|
62
|
+
Metrics
|
|
63
|
+
|
|
64
|
+
import { getMetrics } from '@meeovi/social'
|
|
65
|
+
|
|
66
|
+
console.log(getMetrics('atproto'))
|
|
67
|
+
🧱 Folder Structure
|
|
68
|
+
Code
|
|
69
|
+
src/
|
|
70
|
+
providers/
|
|
71
|
+
config.
|
|
72
|
+
registry.
|
|
73
|
+
useSocial.
|
|
74
|
+
cache.
|
|
75
|
+
retry.
|
|
76
|
+
swr.
|
|
77
|
+
rateLimit.
|
|
78
|
+
metrics.
|
|
79
|
+
utils.
|
package/index.ts
CHANGED
package/package.json
CHANGED
package/src/cache.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
value: T
|
|
3
|
+
expiresAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SocialCache {
|
|
7
|
+
store: any
|
|
8
|
+
get<T>(key: string): T | null
|
|
9
|
+
set<T>(key: string, value: T, ttlMs: number): void
|
|
10
|
+
delete(key: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cache: SocialCache = {
|
|
14
|
+
store: new Map<string, CacheEntry<any>>(),
|
|
15
|
+
|
|
16
|
+
get<T>(key: string): T | null {
|
|
17
|
+
const entry = this.store.get(key)
|
|
18
|
+
if (!entry) return null
|
|
19
|
+
if (Date.now() > entry.expiresAt) {
|
|
20
|
+
this.store.delete(key)
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
return entry.value
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
set<T>(key: string, value: T, ttlMs: number) {
|
|
27
|
+
this.store.set(key, {
|
|
28
|
+
value,
|
|
29
|
+
expiresAt: Date.now() + ttlMs
|
|
30
|
+
})
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
delete(key: string) {
|
|
34
|
+
this.store.delete(key)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setSocialCache(customCache: SocialCache) {
|
|
39
|
+
cache = customCache
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSocialCache() {
|
|
43
|
+
return cache
|
|
44
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,27 +1,18 @@
|
|
|
1
|
-
|
|
2
1
|
export interface SocialConfig {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
twitter?: boolean
|
|
8
|
-
apple?: boolean
|
|
9
|
-
[key: string]: any
|
|
10
|
-
}
|
|
11
|
-
redirectUrl?: string
|
|
2
|
+
provider: string
|
|
3
|
+
baseUrl?: string
|
|
4
|
+
apiKey?: string
|
|
5
|
+
[key: string]: any
|
|
12
6
|
}
|
|
13
7
|
|
|
14
8
|
let config: SocialConfig = {
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
provider: 'atproto',
|
|
10
|
+
baseUrl: '',
|
|
11
|
+
apiKey: ''
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
export function setSocialConfig(newConfig: Partial<SocialConfig>) {
|
|
20
|
-
config = {
|
|
21
|
-
...config,
|
|
22
|
-
providers: { ...config.providers, ...(newConfig.providers || {}) },
|
|
23
|
-
redirectUrl: newConfig.redirectUrl ?? config.redirectUrl
|
|
24
|
-
}
|
|
15
|
+
config = { ...config, ...newConfig }
|
|
25
16
|
}
|
|
26
17
|
|
|
27
18
|
export function getSocialConfig(): SocialConfig {
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class SocialError extends Error {
|
|
2
|
+
constructor(message: string, public provider?: string, public cause?: any) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = 'SocialError'
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class RateLimitError extends SocialError {
|
|
9
|
+
retryAfter?: number
|
|
10
|
+
|
|
11
|
+
constructor(message: string, provider?: string, retryAfter?: number, cause?: any) {
|
|
12
|
+
super(message, provider, cause)
|
|
13
|
+
this.name = 'RateLimitError'
|
|
14
|
+
this.retryAfter = retryAfter
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface SocialMetrics {
|
|
2
|
+
requests: number
|
|
3
|
+
successes: number
|
|
4
|
+
failures: number
|
|
5
|
+
cacheHits: number
|
|
6
|
+
cacheMisses: number
|
|
7
|
+
backgroundRefreshes: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const metrics = new Map<string, SocialMetrics>()
|
|
11
|
+
|
|
12
|
+
function getProviderMetrics(provider: string): SocialMetrics {
|
|
13
|
+
if (!metrics.has(provider)) {
|
|
14
|
+
metrics.set(provider, {
|
|
15
|
+
requests: 0,
|
|
16
|
+
successes: 0,
|
|
17
|
+
failures: 0,
|
|
18
|
+
cacheHits: 0,
|
|
19
|
+
cacheMisses: 0,
|
|
20
|
+
backgroundRefreshes: 0
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
return metrics.get(provider)!
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function recordRequest(provider: string) {
|
|
27
|
+
getProviderMetrics(provider).requests++
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recordSuccess(provider: string) {
|
|
31
|
+
getProviderMetrics(provider).successes++
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function recordFailure(provider: string) {
|
|
35
|
+
getProviderMetrics(provider).failures++
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function recordCacheHit(provider: string) {
|
|
39
|
+
getProviderMetrics(provider).cacheHits++
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordCacheMiss(provider: string) {
|
|
43
|
+
getProviderMetrics(provider).cacheMisses++
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recordBackgroundRefresh(provider: string) {
|
|
47
|
+
getProviderMetrics(provider).backgroundRefreshes++
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getMetrics(provider: string) {
|
|
51
|
+
return getProviderMetrics(provider)
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { wrapSocialRequest } from '../utils'
|
|
2
|
+
import { getSocialConfig } from '../config'
|
|
3
|
+
import { registerSocialProvider } from '../registry'
|
|
4
|
+
|
|
5
|
+
async function atprotoFetch(path: string, options: RequestInit = {}) {
|
|
6
|
+
const { baseUrl, apiKey } = getSocialConfig()
|
|
7
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
8
|
+
...options,
|
|
9
|
+
headers: {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
12
|
+
...(options.headers || {})
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const error: any = new Error(`ATProto error: ${res.status}`)
|
|
18
|
+
error.status = res.status
|
|
19
|
+
error.response = res
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return res.json()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AtprotoProvider = {
|
|
27
|
+
async getProfile(handle: string) {
|
|
28
|
+
return wrapSocialRequest(
|
|
29
|
+
'atproto',
|
|
30
|
+
async () => {
|
|
31
|
+
const data = await atprotoFetch(`/xrpc/app.bsky.actor.getProfile?actor=${handle}`)
|
|
32
|
+
return {
|
|
33
|
+
id: data.did,
|
|
34
|
+
username: data.handle,
|
|
35
|
+
displayName: data.displayName,
|
|
36
|
+
avatarUrl: data.avatar,
|
|
37
|
+
url: `https://bsky.app/profile/${data.handle}`
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
cacheKey: `atproto:profile:${handle}`,
|
|
42
|
+
ttlMs: 1000 * 60 * 5, // 5 minutes
|
|
43
|
+
retry: true
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async listPosts(handle: string) {
|
|
49
|
+
return wrapSocialRequest(
|
|
50
|
+
'atproto',
|
|
51
|
+
async () => {
|
|
52
|
+
const data = await atprotoFetch(`/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}`)
|
|
53
|
+
return (data.feed || []).map((item: any) => ({
|
|
54
|
+
id: item.post.uri,
|
|
55
|
+
content: item.post.record?.text,
|
|
56
|
+
createdAt: item.post.record?.createdAt,
|
|
57
|
+
author: {
|
|
58
|
+
id: item.post.author.did,
|
|
59
|
+
username: item.post.author.handle,
|
|
60
|
+
displayName: item.post.author.displayName,
|
|
61
|
+
avatarUrl: item.post.author.avatar
|
|
62
|
+
},
|
|
63
|
+
url: `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri}`
|
|
64
|
+
}))
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
cacheKey: `atproto:posts:${handle}`,
|
|
68
|
+
ttlMs: 1000 * 30, // 30 seconds
|
|
69
|
+
retry: true
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
registerSocialProvider('atproto', AtprotoProvider)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/providers/mastodon.ts
|
|
2
|
+
import type { SocialProvider, SocialProfile, SocialPost } from '../types'
|
|
3
|
+
import { registerSocialProvider } from '../registry'
|
|
4
|
+
import { getSocialConfig } from '../config'
|
|
5
|
+
|
|
6
|
+
async function mastodonFetch(path: string, options: RequestInit = {}) {
|
|
7
|
+
const { baseUrl, apiKey } = getSocialConfig()
|
|
8
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
9
|
+
...options,
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
13
|
+
...(options.headers || {})
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
if (!res.ok) throw new Error(`Mastodon error: ${res.status}`)
|
|
17
|
+
return res.json()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MastodonProvider: SocialProvider = {
|
|
21
|
+
async getProfile(handle: string): Promise<SocialProfile> {
|
|
22
|
+
const data = await mastodonFetch(`/api/v1/accounts/lookup?acct=${handle}`)
|
|
23
|
+
return {
|
|
24
|
+
id: data.id,
|
|
25
|
+
username: data.acct,
|
|
26
|
+
displayName: data.display_name,
|
|
27
|
+
avatarUrl: data.avatar,
|
|
28
|
+
url: data.url
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async listPosts(handle: string): Promise<SocialPost[]> {
|
|
33
|
+
const profile = await this.getProfile(handle)
|
|
34
|
+
const data = await mastodonFetch(`/api/v1/accounts/${profile.id}/statuses`)
|
|
35
|
+
return data.map((status: any) => ({
|
|
36
|
+
id: status.id,
|
|
37
|
+
content: status.content,
|
|
38
|
+
createdAt: status.created_at,
|
|
39
|
+
author: profile,
|
|
40
|
+
url: status.url
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
registerSocialProvider('mastodon', MastodonProvider)
|
package/src/rateLimit.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RateLimitError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export interface RateLimitConfig {
|
|
4
|
+
maxRequests: number
|
|
5
|
+
windowMs: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const providerLimits = new Map<string, RateLimitConfig>()
|
|
9
|
+
const providerUsage = new Map<string, { count: number; resetAt: number }>()
|
|
10
|
+
|
|
11
|
+
export function setRateLimit(provider: string, config: RateLimitConfig) {
|
|
12
|
+
providerLimits.set(provider, config)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function checkRateLimit(provider: string) {
|
|
16
|
+
const limit = providerLimits.get(provider)
|
|
17
|
+
if (!limit) return
|
|
18
|
+
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
const usage = providerUsage.get(provider) || {
|
|
21
|
+
count: 0,
|
|
22
|
+
resetAt: now + limit.windowMs
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (now > usage.resetAt) {
|
|
26
|
+
usage.count = 0
|
|
27
|
+
usage.resetAt = now + limit.windowMs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
usage.count++
|
|
31
|
+
|
|
32
|
+
providerUsage.set(provider, usage)
|
|
33
|
+
|
|
34
|
+
if (usage.count > limit.maxRequests) {
|
|
35
|
+
throw new RateLimitError(
|
|
36
|
+
`Meeovi internal rate limit exceeded for provider "${provider}"`,
|
|
37
|
+
provider,
|
|
38
|
+
Math.ceil((usage.resetAt - now) / 1000)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SocialProvider } from './types'
|
|
2
|
+
|
|
3
|
+
const providers: Record<string, SocialProvider> = {}
|
|
4
|
+
|
|
5
|
+
export function registerSocialProvider(name: string, provider: SocialProvider) {
|
|
6
|
+
providers[name] = provider
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getSocialProvider(name: string): SocialProvider {
|
|
10
|
+
const provider = providers[name]
|
|
11
|
+
if (!provider) throw new Error(`Social provider "${name}" not found`)
|
|
12
|
+
return provider
|
|
13
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
retries: number
|
|
3
|
+
baseDelayMs: number
|
|
4
|
+
maxDelayMs: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const defaultRetry: RetryOptions = {
|
|
8
|
+
retries: 3,
|
|
9
|
+
baseDelayMs: 300,
|
|
10
|
+
maxDelayMs: 5000
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sleep(ms: number) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function withRetry<T>(
|
|
18
|
+
fn: () => Promise<T>,
|
|
19
|
+
opts: Partial<RetryOptions> = {}
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const { retries, baseDelayMs, maxDelayMs } = { ...defaultRetry, ...opts }
|
|
22
|
+
|
|
23
|
+
let attempt = 0
|
|
24
|
+
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
return await fn()
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
attempt++
|
|
30
|
+
if (attempt > retries) throw err
|
|
31
|
+
|
|
32
|
+
const delay = Math.min(
|
|
33
|
+
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100,
|
|
34
|
+
maxDelayMs
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
await sleep(delay)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/swr.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getSocialCache } from './cache'
|
|
2
|
+
|
|
3
|
+
const refreshQueue = new Map<string, Promise<any>>()
|
|
4
|
+
|
|
5
|
+
export function scheduleBackgroundRefresh(
|
|
6
|
+
key: string,
|
|
7
|
+
fn: () => Promise<any>,
|
|
8
|
+
ttlMs: number
|
|
9
|
+
) {
|
|
10
|
+
if (refreshQueue.has(key)) return
|
|
11
|
+
|
|
12
|
+
const promise = fn()
|
|
13
|
+
.then((fresh) => {
|
|
14
|
+
const cache = getSocialCache()
|
|
15
|
+
cache.set(key, fresh, ttlMs)
|
|
16
|
+
})
|
|
17
|
+
.finally(() => {
|
|
18
|
+
refreshQueue.delete(key)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
refreshQueue.set(key, promise)
|
|
22
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SocialProfile {
|
|
2
|
+
id: string
|
|
3
|
+
username: string
|
|
4
|
+
displayName?: string
|
|
5
|
+
avatarUrl?: string
|
|
6
|
+
url?: string
|
|
7
|
+
[key: string]: any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SocialPost {
|
|
11
|
+
id: string
|
|
12
|
+
content: string
|
|
13
|
+
createdAt: string
|
|
14
|
+
author: SocialProfile
|
|
15
|
+
url?: string
|
|
16
|
+
[key: string]: any
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SocialActivity extends SocialPost {
|
|
20
|
+
provider: string
|
|
21
|
+
source?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SocialProvider {
|
|
25
|
+
getProfile(handle: string): Promise<SocialProfile>
|
|
26
|
+
listPosts(handle: string, options?: Record<string, any>): Promise<SocialPost[]>
|
|
27
|
+
createPost?(content: string, options?: Record<string, any>): Promise<SocialPost>
|
|
28
|
+
}
|
package/src/useSocial.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getSocialConfig } from './config'
|
|
2
|
+
import { getSocialProvider } from './registry'
|
|
3
|
+
import type { SocialActivity } from './types'
|
|
4
|
+
|
|
5
|
+
export function useSocial() {
|
|
6
|
+
const { provider } = getSocialConfig()
|
|
7
|
+
const socialProvider = getSocialProvider(provider)
|
|
8
|
+
|
|
9
|
+
async function getActivityFeed(handle: string, options?: Record<string, any>): Promise<SocialActivity[]> {
|
|
10
|
+
const posts = await socialProvider.listPosts(handle, options)
|
|
11
|
+
|
|
12
|
+
return posts.map((post) => ({
|
|
13
|
+
...post,
|
|
14
|
+
provider,
|
|
15
|
+
source: provider
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
getProfile: socialProvider.getProfile,
|
|
21
|
+
listPosts: socialProvider.listPosts,
|
|
22
|
+
createPost: socialProvider.createPost,
|
|
23
|
+
getActivityFeed
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// utils.ts
|
|
2
|
+
|
|
3
|
+
import { RateLimitError, SocialError } from './errors'
|
|
4
|
+
import { getSocialCache } from './cache'
|
|
5
|
+
import { withRetry } from './retry'
|
|
6
|
+
import { scheduleBackgroundRefresh } from './swr'
|
|
7
|
+
import { checkRateLimit } from './rateLimit'
|
|
8
|
+
import {
|
|
9
|
+
recordRequest,
|
|
10
|
+
recordSuccess,
|
|
11
|
+
recordFailure,
|
|
12
|
+
recordCacheHit,
|
|
13
|
+
recordCacheMiss
|
|
14
|
+
} from './metrics'
|
|
15
|
+
|
|
16
|
+
export interface RequestOptions {
|
|
17
|
+
cacheKey?: string
|
|
18
|
+
ttlMs?: number
|
|
19
|
+
retry?: boolean
|
|
20
|
+
swr?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Core wrapper for all provider requests.
|
|
25
|
+
* Handles:
|
|
26
|
+
* - caching
|
|
27
|
+
* - SWR background refresh
|
|
28
|
+
* - retry/backoff
|
|
29
|
+
* - provider-specific rate limits
|
|
30
|
+
* - unified error handling
|
|
31
|
+
* - analytics hooks
|
|
32
|
+
*/
|
|
33
|
+
export async function wrapSocialRequest<T>(
|
|
34
|
+
provider: string,
|
|
35
|
+
fn: () => Promise<T>,
|
|
36
|
+
opts: RequestOptions = {}
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const cache = getSocialCache()
|
|
39
|
+
|
|
40
|
+
// Track request attempt
|
|
41
|
+
recordRequest(provider)
|
|
42
|
+
|
|
43
|
+
// Provider-specific rate limit enforcement
|
|
44
|
+
checkRateLimit(provider)
|
|
45
|
+
|
|
46
|
+
// 1. Cache check
|
|
47
|
+
if (opts.cacheKey) {
|
|
48
|
+
const cached = cache.get<T>(opts.cacheKey)
|
|
49
|
+
|
|
50
|
+
if (cached) {
|
|
51
|
+
recordCacheHit(provider)
|
|
52
|
+
|
|
53
|
+
// SWR: return cached immediately, refresh in background
|
|
54
|
+
if (opts.swr && opts.ttlMs) {
|
|
55
|
+
scheduleBackgroundRefresh(opts.cacheKey, fn, opts.ttlMs)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return cached
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
recordCacheMiss(provider)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Execute request (with optional retry/backoff)
|
|
65
|
+
let result: T
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
result = await (opts.retry ? withRetry(fn) : fn())
|
|
69
|
+
recordSuccess(provider)
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
recordFailure(provider)
|
|
72
|
+
|
|
73
|
+
const status = err?.status || err?.response?.status
|
|
74
|
+
const retryAfterHeader = err?.response?.headers?.['retry-after']
|
|
75
|
+
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : undefined
|
|
76
|
+
|
|
77
|
+
if (status === 429) {
|
|
78
|
+
throw new RateLimitError(
|
|
79
|
+
`Rate limit exceeded for provider "${provider}"`,
|
|
80
|
+
provider,
|
|
81
|
+
retryAfter,
|
|
82
|
+
err
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new SocialError(
|
|
87
|
+
`Social provider "${provider}" request failed`,
|
|
88
|
+
provider,
|
|
89
|
+
err
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Cache fresh result
|
|
94
|
+
if (opts.cacheKey && opts.ttlMs) {
|
|
95
|
+
cache.set(opts.cacheKey, result, opts.ttlMs)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
}
|