Files
maarcadetweet/crates/appview/src/main.rs
T
tomdeboneandClaude Opus 5 3c6f4dd67c chore(config): Binaries laden .env selbst; .env.example korrigiert
`cp .env.example .env && cargo run` — der im README dokumentierte Ablauf —
schlug bisher mit `missing env: PDS_HOST` fehl: nichts im Prozess hat die
Datei je gelesen. Beide Bins rufen jetzt als erstes `dotenvy::dotenv()` auf;
echte Umgebungsvariablen gewinnen weiterhin.

Dazu .env.example am Code verifiziert:

* PDS_JWT_SECRET war weder Hex noch ein gültiger P-256-Skalar. jwt_issuer.rs
  macht hex::decode + p256::SecretKey::from_bytes; ein ungültiger Wert lässt
  den Server starten, aber jeder Pfad über server_p256_public_multibase
  antwortet 500 — also nicht nur create/refreshSession, sondern auch jeder
  Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
* JETSTREAM_COLLECTIONS fehlten app.twi.post (das eigene 160-Zeichen-Lexicon)
  und app.bsky.actor.profile, obwohl der Indexer beide verarbeitet.
* APP_ENV entfernt — wird nirgends gelesen.
* PDS_INTERNAL_URL, APPVIEW_INTERNAL_URL, APPVIEW_HANDLE_SYNC_INTERVAL_SECS
  und die MAARCADETWEET_*-Overrides des Clients ergänzt.
* S3_BUCKET_APPVIEW als das markiert, was es ist: Pflichtvariable ohne Leser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:35:53 +02:00

131 lines
5.0 KiB
Rust

use anyhow::Result;
use at_identity::DidHandleResolver;
use at_shared::config::AppConfig;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod firehose;
mod handle_sync;
mod indexer;
mod ingest;
mod routes;
mod state;
use state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
// Install a rustls crypto provider before any TLS connection. `ring`
// is the only one we currently support; using `aws_lc_rs` would
// require a non-default feature on rustls.
let _ = rustls::crypto::ring::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cfg = AppConfig::from_env()?;
let db = sqlx::PgPool::connect(&cfg.database_url_appview).await?;
sqlx::migrate!("../../migrations/appview").run(&db).await?;
// Read the last persisted cursor so we resume after restart instead of
// missing events that landed in the gap between (a) the last value we
// wrote and (b) Jetstream's default backfill window.
let start_cursor = indexer::cursor_get(&db).await.unwrap_or(0);
if start_cursor > 0 {
info!(cursor = start_cursor, "resuming Jetstream from last persisted cursor");
}
// Bounded channel for "cursor wants to advance" signals. We push one
// tick per ~100 events from the consumer thread; the flush task drains
// the channel and writes a single batched UPDATE.
let (cursor_tx, cursor_rx) = mpsc::channel::<i64>(32);
let stats = firehose::Stats::new();
// Spawn the cursor-flush task. It lives for the whole process — when
// the receiver end drops (which only happens at shutdown), the task
// does a final flush and exits.
let _cursor_task = firehose::spawn_cursor_flush(db.clone(), cursor_rx, stats.clone());
{
let mut jetstream = at_firehose::JetstreamConsumer::new(
cfg.jetstream_url.clone(),
cfg.jetstream_collections.clone(),
)
.with_connected_flag(stats.jetstream_connected_arc())
.with_max_backoff_secs(30);
if start_cursor > 0 {
jetstream = jetstream.with_cursor(start_cursor);
}
let handler = firehose::IndexHandler::new(db.clone(), stats.clone(), cursor_tx.clone());
tokio::spawn(async move {
if let Err(e) = jetstream
.run(move |ev| {
let h = handler.clone();
async move { h.handle(ev).await }
})
.await
{
tracing::error!("jetstream terminated: {e:#}");
}
});
}
let state = AppState::new(cfg.clone(), db.clone(), stats.clone());
// Back-fill the `handle` column on posts that the Jetstream
// indexer inserted with an empty placeholder. The worker dispatches
// by DID method: `did:plc:` → PLC directory, `did:web:` → a
// WebResolver that fetches the host's `.well-known/did.json`.
// Anything else (e.g. `did:key:`) is silently skipped.
let plc: Arc<dyn DidHandleResolver> = Arc::new(at_identity::PlcClient::new(
cfg.plc_directory_url.clone(),
));
let web: Arc<dyn DidHandleResolver> = Arc::new(at_identity::WebResolver::new());
// PDS-local handle resolver. The handle_sync worker consults it
// first, before the public PLC/web resolvers, so `did:key:`
// users (and any other DID the operator hosts on this PDS) get their
// local handle without a round trip to plc.directory. A 404 from
// the PDS falls through to the public resolvers.
//
// Use the cluster-internal URL when configured (e.g. `http://pds:3000`
// inside docker compose) — `pds_public_url` may not be reachable
// from inside the cluster when TLS / DNS is set up for outside
// clients only.
let pds_base_url = cfg
.pds_internal_url
.clone()
.unwrap_or_else(|| cfg.pds_public_url.clone());
let pds_resolver: Arc<dyn at_identity::DidHandleResolver> = Arc::new(
at_identity::pds_handle::PdsHandleResolver::new(pds_base_url),
);
let handle_sync = handle_sync::HandleSyncWorker {
db: db.clone(),
pds_resolver,
plc_resolver: plc,
web_resolver: web,
interval_secs: cfg.appview_handle_sync_interval_secs,
};
tokio::spawn(async move {
handle_sync.run_forever().await;
});
let app = routes::router(state);
let addr: SocketAddr = format!("{}:{}", cfg.appview_host, cfg.appview_port).parse()?;
info!("appview listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}