Add Nostr relay + seed data to Docker deployment
- Add nostr-rs-relay service to docker-compose for persistent comments, reactions, and profiles on the dev server - Add one-shot seeder container that auto-populates the relay with test personas, reactions, and comments on first deploy - Proxy WebSocket connections through nginx at /relay so the frontend connects to the relay on the same host (no CORS) - Make relay URL dynamic: reads from VITE_NOSTR_RELAYS in dev, auto-detects /relay proxy path in production Docker builds - Make seed scripts configurable via RELAY_URL and ORIGIN env vars - Add wait-for-relay script for reliable container orchestration - Add "Resume last played" hero banner on My List tab Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,10 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Clear VITE_NOSTR_RELAYS so the app auto-detects the relay
|
||||||
|
# via the /relay nginx proxy at runtime (instead of hardcoding localhost)
|
||||||
|
ENV VITE_NOSTR_RELAYS=""
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
23
Dockerfile.seed
Normal file
23
Dockerfile.seed
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Seeder container — populates the Nostr relay with test profiles,
|
||||||
|
# reactions, and comments so the dev deployment has content.
|
||||||
|
#
|
||||||
|
# Runs once and exits. docker-compose "restart: no" keeps it from looping.
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# Copy only what the seed scripts need
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
COPY src/data/testPersonas.ts ./src/data/testPersonas.ts
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Default env (overridden by docker-compose)
|
||||||
|
ENV RELAY_URL=ws://relay:8080
|
||||||
|
ENV ORIGIN=http://localhost:7777
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node scripts/wait-for-relay.mjs && npx tsx scripts/seed-profiles.ts && npx tsx scripts/seed-activity.ts && echo '✅ Seeding complete!'"]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
indeedhub:
|
# ── Frontend (nginx serving built Vue app) ───────────────────
|
||||||
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -9,19 +10,49 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "7777:7777"
|
- "7777:7777"
|
||||||
environment:
|
depends_on:
|
||||||
- NODE_ENV=production
|
seeder:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- indeedhub-network
|
- indeedhub-network
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:7777/"]
|
test: ["CMD", "curl", "-f", "http://localhost:7777/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
# ── Nostr Relay (stores comments, reactions, profiles) ───────
|
||||||
|
relay:
|
||||||
|
image: scsibug/nostr-rs-relay:latest
|
||||||
|
container_name: indeedhub-relay
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- relay-data:/usr/src/app/db
|
||||||
|
networks:
|
||||||
|
- indeedhub-network
|
||||||
|
|
||||||
|
# ── Seeder (one-shot: seeds test data into relay, then exits) ─
|
||||||
|
# wait-for-relay.mjs handles readiness polling before seeding.
|
||||||
|
seeder:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.seed
|
||||||
|
container_name: indeedhub-seeder
|
||||||
|
depends_on:
|
||||||
|
- relay
|
||||||
|
environment:
|
||||||
|
- RELAY_URL=ws://relay:8080
|
||||||
|
- ORIGIN=http://localhost:7777
|
||||||
|
networks:
|
||||||
|
- indeedhub-network
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
indeedhub-network:
|
indeedhub-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
relay-data:
|
||||||
|
|||||||
16
nginx.conf
16
nginx.conf
@@ -27,6 +27,22 @@ server {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy to Nostr relay (Docker service)
|
||||||
|
location /relay {
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
set $relay_upstream http://relay:8080;
|
||||||
|
|
||||||
|
rewrite ^/relay(.*) /$1 break;
|
||||||
|
proxy_pass $relay_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
# Vue Router - SPA fallback
|
# Vue Router - SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
TASTEMAKER_PERSONAS,
|
TASTEMAKER_PERSONAS,
|
||||||
} from '../src/data/testPersonas.js'
|
} from '../src/data/testPersonas.js'
|
||||||
|
|
||||||
const RELAY_URL = 'ws://localhost:7777'
|
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||||
const ORIGIN = 'http://localhost:5174'
|
const ORIGIN = process.env.ORIGIN || 'http://localhost:5174'
|
||||||
|
|
||||||
// ── Content catalog (matching src/data/indeeHubFilms.ts) ──────────
|
// ── Content catalog (matching src/data/indeeHubFilms.ts) ──────────
|
||||||
const CONTENT = [
|
const CONTENT = [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
TASTEMAKER_PERSONAS,
|
TASTEMAKER_PERSONAS,
|
||||||
} from '../src/data/testPersonas.js'
|
} from '../src/data/testPersonas.js'
|
||||||
|
|
||||||
const RELAY_URL = 'ws://localhost:7777'
|
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||||
|
|
||||||
type Persona = { name: string; nsec: string; pubkey: string }
|
type Persona = { name: string; nsec: string; pubkey: string }
|
||||||
|
|
||||||
|
|||||||
40
scripts/wait-for-relay.mjs
Normal file
40
scripts/wait-for-relay.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Waits for the Nostr relay to be reachable before seeding.
|
||||||
|
* Used by the Docker seeder container.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/wait-for-relay.mjs
|
||||||
|
* Env: RELAY_URL (default ws://localhost:7777)
|
||||||
|
*/
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
const wsUrl = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||||
|
const httpUrl = wsUrl.replace('ws://', 'http://').replace('wss://', 'https://')
|
||||||
|
const maxAttempts = 30
|
||||||
|
const intervalMs = 2000
|
||||||
|
|
||||||
|
console.log(`Waiting for relay at ${httpUrl} ...`)
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxAttempts; i++) {
|
||||||
|
const ok = await new Promise((resolve) => {
|
||||||
|
const req = http.get(httpUrl, (res) => {
|
||||||
|
res.resume() // drain response
|
||||||
|
resolve(res.statusCode >= 200 && res.statusCode < 400)
|
||||||
|
})
|
||||||
|
req.on('error', () => resolve(false))
|
||||||
|
req.setTimeout(3000, () => {
|
||||||
|
req.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
console.log(`Relay is ready! (attempt ${i}/${maxAttempts})`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` attempt ${i}/${maxAttempts} — not ready yet`)
|
||||||
|
await new Promise((r) => setTimeout(r, intervalMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Relay did not become ready after ${maxAttempts} attempts`)
|
||||||
|
process.exit(1)
|
||||||
@@ -9,8 +9,27 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
// Relay pool for all WebSocket connections
|
// Relay pool for all WebSocket connections
|
||||||
export const pool = new RelayPool()
|
export const pool = new RelayPool()
|
||||||
|
|
||||||
// App relays (local dev relay)
|
/**
|
||||||
export const APP_RELAYS = ['ws://localhost:7777']
|
* Determine app relay URLs at runtime.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. VITE_NOSTR_RELAYS env var (set in .env for local dev)
|
||||||
|
* 2. Auto-detect: proxy through same host at /relay (Docker / production)
|
||||||
|
*/
|
||||||
|
function getAppRelays(): string[] {
|
||||||
|
const envRelays = import.meta.env.VITE_NOSTR_RELAYS as string | undefined
|
||||||
|
if (envRelays) {
|
||||||
|
const parsed = envRelays.split(',').map((r: string) => r.trim()).filter(Boolean)
|
||||||
|
if (parsed.length > 0) return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production / Docker: relay is proxied through nginx at /relay
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return [`${protocol}//${window.location.host}/relay`]
|
||||||
|
}
|
||||||
|
|
||||||
|
// App relays (local dev relay or proxied production relay)
|
||||||
|
export const APP_RELAYS = getAppRelays()
|
||||||
|
|
||||||
// Lookup relays for profile metadata
|
// Lookup relays for profile metadata
|
||||||
export const LOOKUP_RELAYS = ['wss://purplepag.es']
|
export const LOOKUP_RELAYS = ['wss://purplepag.es']
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
<!-- Hero Content -->
|
<!-- Hero Content -->
|
||||||
<div class="relative mx-auto px-4 md:px-8 h-full flex items-center pt-16" style="max-width: 90%;">
|
<div class="relative mx-auto px-4 md:px-8 h-full flex items-center pt-16" style="max-width: 90%;">
|
||||||
<div class="max-w-2xl space-y-2.5 md:space-y-3 animate-fade-in">
|
<div class="max-w-2xl space-y-2.5 md:space-y-3 animate-fade-in">
|
||||||
|
|
||||||
|
<!-- Resume label (My List only) -->
|
||||||
|
<div v-if="isMyListTab && resumeItem" class="flex items-center gap-2 text-xs md:text-sm text-white/70 uppercase tracking-widest font-medium">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Continue where you left off
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h1 class="hero-title w-full text-3xl md:text-5xl lg:text-6xl font-bold drop-shadow-2xl leading-tight uppercase" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;">
|
<h1 class="hero-title w-full text-3xl md:text-5xl lg:text-6xl font-bold drop-shadow-2xl leading-tight uppercase" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;">
|
||||||
{{ featuredContent?.title || 'GOD BLESS BITCOIN' }}
|
{{ featuredContent?.title || 'GOD BLESS BITCOIN' }}
|
||||||
@@ -30,8 +39,16 @@
|
|||||||
{{ featuredContent?.description || 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin.' }}
|
{{ featuredContent?.description || 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin.' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Meta Info -->
|
<!-- Progress bar (My List resume) -->
|
||||||
<div v-if="featuredContent" class="flex items-center gap-2.5 text-xs md:text-sm text-white/80">
|
<div v-if="isMyListTab && resumeItem" class="flex items-center gap-3 pt-0.5">
|
||||||
|
<div class="flex-1 h-1.5 bg-white/15 rounded-full overflow-hidden max-w-xs">
|
||||||
|
<div class="h-full bg-white/80 rounded-full transition-all duration-500" :style="{ width: `${resumeItem.progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-white/60 font-medium">{{ resumeItem.progress }}% watched</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta Info (non-resume) -->
|
||||||
|
<div v-else-if="featuredContent" class="flex items-center gap-2.5 text-xs md:text-sm text-white/80">
|
||||||
<span v-if="featuredContent.rating" class="bg-white/20 px-2.5 py-0.5 rounded">{{ featuredContent.rating }}</span>
|
<span v-if="featuredContent.rating" class="bg-white/20 px-2.5 py-0.5 rounded">{{ featuredContent.rating }}</span>
|
||||||
<span v-if="featuredContent.releaseYear">{{ featuredContent.releaseYear }}</span>
|
<span v-if="featuredContent.releaseYear">{{ featuredContent.releaseYear }}</span>
|
||||||
<span v-if="featuredContent.duration">{{ featuredContent.duration }}min</span>
|
<span v-if="featuredContent.duration">{{ featuredContent.duration }}min</span>
|
||||||
@@ -44,7 +61,7 @@
|
|||||||
<svg class="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Play
|
{{ isMyListTab && resumeItem ? 'Resume' : 'Play' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="handleInfoClick" class="hero-info-button flex items-center gap-2">
|
<button @click="handleInfoClick" class="hero-info-button flex items-center gap-2">
|
||||||
<svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
Reference in New Issue
Block a user