This document provides context for AI coding agents working on divine-web.
diVine is a decentralized short-form video platform built on the Nostr protocol. Think "TikTok on Nostr" with 6-second looping videos (inspired by Vine). The codebase is a React 18.x SPA using Vite, TailwindCSS, shadcn/ui, and TanStack Query.
- Fast, responsive video feeds with instant loading
- Decentralized architecture using Nostr protocol
- Preserve and celebrate the classic Vine archive
- Human-authentic content (anti-AI slop philosophy)
- RED: Write failing tests first
- GREEN: Write minimum code to pass
- REFACTOR: Improve without changing behavior
- Single Responsibility: Each function has ONE job
- DRY: Don't repeat yourself - extract shared logic
- Pure Functions: Transform functions have no side effects
- Clear Naming: Functions named as verb+noun (fetchUserProfile, transformToStats)
- No God Functions: Keep functions <50 lines
Components (UI) → Hooks (Orchestration) → Client (HTTP) → Transform (Mapping)
↓ fallback
WebSocket queries
When deploying to Fastly, ALWAYS run BOTH commands:
npm run fastly:deploy- Deploys the edge worker (Wasm compute)npm run fastly:publish- Publishes static content to KV Store
Running only deploy without publish means the new frontend code won't be served!
npm run deploy:cloudflare- Deploy to Cloudflare Pages
- Commit format:
type: description(feat, fix, perf, docs, refactor, test) - Include
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>when AI-assisted - Don't amend commits after hook failures - create new commits
Funnelcake is our optimized REST API layer. Use REST for reads, WebSocket for writes.
| Environment | WebSocket | REST API |
|---|---|---|
| Production | wss://relay.divine.video |
https://api.divine.video/api/ |
| Staging | wss://relay.staging.dvines.org |
https://relay.staging.dvines.org/api/ |
Canonical production REST traffic goes to https://api.divine.video/api/.
https://relay.divine.video/api/ remains the uncached backup path.
OpenAPI Docs: https://api.divine.video/docs
- REST: Analytics, stats, bulk operations, search, pre-computed data
- WebSocket: Publishing events, real-time subscriptions, signature verification
GET /api/videos - List videos (sort: trending|recent|loops)
GET /api/videos/{id} - Single video with stats
POST /api/videos/stats/bulk - Bulk video stats
GET /api/users/{pubkey} - User profile + stats
GET /api/users/{pubkey}/videos - User's videos
GET /api/users/{pubkey}/followers - Paginated followers
GET /api/users/{pubkey}/following - Following list
POST /api/users/bulk - Bulk user profiles
GET /api/search?q= - Full-text search
GET /api/hashtags/trending - Trending hashtags
Bulk endpoints support from_event to resolve IDs from another event:
// Get profiles of everyone a user follows
POST /api/users/bulk
{ "from_event": { "kind": 3, "pubkey": "user-pubkey" } }
// Get videos from a playlist
POST /api/videos/bulk
{ "from_event": { "kind": 30005, "pubkey": "curator", "d_tag": "playlist" } }The app uses a circuit breaker for Funnelcake API calls:
- After 3 consecutive failures, circuit opens for 30 seconds
- Automatic fallback to WebSocket queries when circuit is open
- Use
isFunnelcakeAvailable()to check status
{
"id": "64-char-hex-sha256",
"pubkey": "64-char-hex-public-key",
"created_at": 1700000000,
"kind": 34236,
"tags": [["d", "unique-id"], ["title", "My Video"]],
"content": "Description",
"sig": "128-char-hex-signature"
}| Kind | Purpose |
|---|---|
| 0 | User profile metadata |
| 3 | Contact/follow list |
| 5 | Deletion requests |
| 7 | Reactions (likes) |
| 16 | Generic repost (for videos) |
| 1111 | Comments (NIP-22) |
| 10003 | Bookmark list |
| 30005 | Curation set / playlist |
| 34236 | Short-form video (NIP-71) |
// Trending videos
{ kinds: [34236], search: "sort:hot", limit: 50 }
// Popular all-time
{ kinds: [34236], search: "sort:top", limit: 50 }
// Combined search + sort
{ kinds: [34236], search: "sort:hot bitcoin", limit: 50 }- Unique key:
pubkey:kind:d-tag - Deduplicate by this key, NOT by event ID
- Publishing same d-tag replaces the event
["d", "unique-video-id"], // REQUIRED
["title", "Video Title"],
["imeta", "url https://...", "m video/mp4", "image https://..."],
["t", "hashtag"]Comments use UPPERCASE for root, lowercase for parent:
{
"kind": 1111,
"tags": [
["E", "<video-id>"], // Root = the video
["K", "34236"], // Root kind
["P", "<video-author>"], // Root author
["e", "<parent-id>"], // Parent (video or comment being replied to)
["k", "34236"], // Parent kind (34236 for video, 1111 for reply)
["p", "<parent-author>"] // Parent author
],
"content": "Great video!"
}// Use React Query for data fetching
const query = useQuery({
queryKey: ['resource', id],
queryFn: async ({ signal }) => {
// Try REST first
if (isFunnelcakeAvailable(apiUrl)) {
const result = await fetchFromRest(apiUrl, id, signal);
if (result) return result;
}
// Fallback to WebSocket
return fetchFromWebSocket(nostr, id, signal);
},
staleTime: 60000,
gcTime: 300000,
});// Pure functions that map API responses to app types
export function transformFunnelcakeProfile(response: ApiResponse): ProfileStats {
return {
followersCount: response.social?.follower_count ?? 0,
followingCount: response.social?.following_count ?? 0,
// ...
};
}// Vitest with React Testing Library
describe('useProfileStats', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('fetches from REST when available', async () => {
mockFetch({ follower_count: 100 });
const { result } = renderHook(() => useProfileStats(PUBKEY));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.followersCount).toBe(100);
});
});src/
├── hooks/ # React Query hooks
│ ├── useProfileStats.ts
│ ├── useBatchedAuthors.ts
│ ├── useInfiniteVideosFunnelcake.ts
│ └── useVideoEvents.ts
├── lib/
│ ├── funnelcakeClient.ts # REST API client
│ ├── funnelcakeHealth.ts # Circuit breaker
│ ├── funnelcakeTransform.ts # Response transforms
│ └── videoParser.ts # Nostr event parsing
├── components/
│ ├── VideoCard.tsx
│ ├── VideoFeed.tsx
│ └── ProfileHeader.tsx
├── types/
│ ├── video.ts
│ └── funnelcake.ts
└── config/
├── api.ts # API configuration
└── relays.ts # Relay configuration
Always deduplicate videos by pubkey:kind:d-tag, NOT by event ID. Different events can represent the same addressable video.
- API uses hex format (64 chars)
- Users share bech32 (
npub1...,note1...) - Always decode bech32 to hex before API calls
Funnelcake profile response is nested:
{
"profile": { "name": "..." },
"social": { "follower_count": 100 },
"stats": { "video_count": 10 }
}- Videos with
loopCount > 0are from the Vine archive - Show "Classic Viner" badge for these users
- Original loop counts are preserved in video metadata
npm test # Full test suite
npx vitest run # Just vitest
npx tsc --noEmit # Type check onlyVITE_FUNNELCAKE_API_URL=https://api.divine.video # Funnelcake API hostnpm run dev # Local development server
npm run build # Production build
npm run fastly:deploy && npm run fastly:publish # Deploy to Fastly
npm run deploy:cloudflare # Deploy to Cloudflare Pages