Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as remaining). Resolves the activities.owner_id open question. Schema (design decision from the open question): - activities.owner_id nullable + check constraint enforcing exactly one of (owner_id, remote_actor_iri) — the follows pattern again. - remote_published_at carries the origin's publish time for the §8 feed sort; index on remote_actor_iri for the feed join. - Compiler-audited fallout: notification fan-out, the Note object dispatcher, and the activity detail loader explicitly skip/404 remote rows (their canonical page is the origin; feed links there). Every other surface joins users on owner_id and excludes remote rows structurally. Ingestion: - federation-ingest.server.ts: parseOutboxItem targets exactly our own outgoing Create(Note) shape (PropertyValue stats, first-paragraph name, audience from to/cc); unknown items skipped, never fatal. - ingestRemoteActivities: replay-safe via unique remote_origin_iri, conflict-streak early exit (outboxes are newest-first). - pollRemoteActor: signed fetch (Authorized Fetch via a local follower's key), outbox resolution via remote_actors cache with actor-doc fallback (which refreshes the cache), 1 req/5 s per-host pacing, last_polled_at stamping. Network + pacing injectable. Jobs: poll-remote-actor (per-actor; the §4 Accept listener already enqueues it as the first poll) + poll-remote-outboxes (5-min cron sweep over accepted remote follows not polled within the hour). Tests: parse-shape units; integration suite for ingestion provenance, audience→visibility mirroring, replay safety, the DB author invariant, poll flow (actor-doc → collection → page), signer requirement, and due-polling selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
8.4 KiB
TypeScript
206 lines
8.4 KiB
TypeScript
import * as Sentry from "@sentry/node";
|
|
import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config";
|
|
import { createRequestListener } from "@react-router/node";
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { createReadStream, statSync } from "node:fs";
|
|
import { join, extname, resolve } from "node:path";
|
|
import { logger, requestContext } from "./app/lib/logger.server.ts";
|
|
import { randomUUID } from "node:crypto";
|
|
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
|
import { createBoss, startWorker } from "@trails-cool/jobs";
|
|
import { getDatabaseUrl } from "@trails-cool/db";
|
|
import postgres, { type Sql } from "postgres";
|
|
|
|
// Sentry DSN is supplied via env. No hardcoded fallback — self-hosted
|
|
// instances default to no Sentry. Flagship sets SENTRY_DSN via
|
|
// docker-compose env (see infrastructure/docker-compose.yml).
|
|
// SENTRY_DISABLED=true is an explicit opt-out even when a DSN is set
|
|
// (useful for local prod-mode debugging).
|
|
const sentryDsn = process.env.SENTRY_DSN;
|
|
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) {
|
|
Sentry.init({
|
|
dsn: sentryDsn,
|
|
...nodeSentryConfig("journal server"),
|
|
beforeSend: drop404s,
|
|
});
|
|
}
|
|
|
|
const port = Number(process.env.PORT ?? 3000);
|
|
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
|
|
|
const MIME: Record<string, string> = {
|
|
".js": "application/javascript",
|
|
".css": "text/css",
|
|
".html": "text/html",
|
|
".json": "application/json",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
};
|
|
|
|
function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
|
|
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
|
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
const filePath = resolve(join(CLIENT_DIR, url.pathname));
|
|
|
|
if (!filePath.startsWith(CLIENT_DIR)) return false;
|
|
|
|
try {
|
|
if (!statSync(filePath).isFile()) return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream");
|
|
if (url.pathname.startsWith("/assets/")) {
|
|
res.setHeader("Cache-Control", "public, immutable, max-age=31536000");
|
|
}
|
|
createReadStream(filePath).pipe(res);
|
|
return true;
|
|
}
|
|
|
|
const listener = createRequestListener({
|
|
build: () => import("./build/server/index.js" as string) as never,
|
|
});
|
|
|
|
async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const metrics = await registry.metrics();
|
|
res.writeHead(200, { "Content-Type": registry.contentType });
|
|
res.end(metrics);
|
|
}
|
|
|
|
const version = process.env.SENTRY_RELEASE ?? "dev";
|
|
|
|
// Module-level singleton postgres client dedicated to /api/health. The
|
|
// previous handler opened a fresh client + connection on every call,
|
|
// which OOM'd the process under monitoring load (probes hit /api/health
|
|
// every few seconds). `max: 2` is plenty for liveness checks; the main
|
|
// app DB pool is separate (via @trails-cool/db's createDb).
|
|
let healthClient: Sql | null = null;
|
|
function getHealthClient(): Sql {
|
|
if (!healthClient) {
|
|
healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 });
|
|
}
|
|
return healthClient;
|
|
}
|
|
|
|
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
try {
|
|
await getHealthClient()`SELECT 1`;
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
|
|
} catch {
|
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
|
|
}
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const url = req.url ?? "/";
|
|
const start = Date.now();
|
|
|
|
// Honor an inbound X-Request-Id header (e.g. from Caddy or a probe)
|
|
// so request IDs propagate across the proxy hop. Mint a fresh one if
|
|
// absent. Echo on the response so clients can correlate.
|
|
const inbound = req.headers["x-request-id"];
|
|
const requestId =
|
|
(Array.isArray(inbound) ? inbound[0] : inbound) || randomUUID();
|
|
res.setHeader("X-Request-Id", requestId);
|
|
|
|
requestContext.run({ requestId }, () => {
|
|
if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") {
|
|
res.on("finish", () => {
|
|
const duration = Date.now() - start;
|
|
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
|
|
httpRequestDuration.observe(
|
|
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
|
|
duration / 1000,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (url === "/api/health") { handleHealth(req, res); return; }
|
|
if (url === "/api/metrics") { handleMetrics(req, res); return; }
|
|
if (!serveStatic(req, res)) {
|
|
listener(req, res);
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(port, async () => {
|
|
logger.info({ port }, "Journal server listening");
|
|
|
|
// Seed first-party OAuth2 clients
|
|
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
|
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
|
|
|
// Pre-flight the demo user so a persona username that clashes with a
|
|
// real account blocks job scheduling rather than silently attaching
|
|
// synthetic rows to a human.
|
|
let enableDemoJobs = false;
|
|
if (process.env.DEMO_BOT_ENABLED === "true") {
|
|
const { ensureDemoUser, DemoPersonaUsernameClashError } = await import(
|
|
"./app/lib/demo-bot.server.ts"
|
|
);
|
|
try {
|
|
const id = await ensureDemoUser();
|
|
logger.info({ id }, "demo-bot user ensured");
|
|
enableDemoJobs = true;
|
|
} catch (err) {
|
|
if (err instanceof DemoPersonaUsernameClashError) {
|
|
logger.error(
|
|
{ username: err.username },
|
|
"demo persona username clash — demo jobs disabled for this process",
|
|
);
|
|
} else {
|
|
logger.error({ err }, "demo-bot ensureDemoUser failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start background job worker
|
|
const jobs: Parameters<typeof startWorker>[1] = [];
|
|
if (enableDemoJobs) {
|
|
const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
|
|
const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
|
|
jobs.push(demoBotGenerateJob, demoBotPruneJob);
|
|
}
|
|
// Notifications jobs always run (no feature flag): a journal without
|
|
// notifications wired up would silently drop fan-out enqueues.
|
|
const { notificationsFanoutJob } = await import("./app/jobs/notifications-fanout.ts");
|
|
const { notificationsPurgeJob } = await import("./app/jobs/notifications-purge.ts");
|
|
const { komootBulkImportJob } = await import("./app/jobs/komoot-bulk-import.ts");
|
|
const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts");
|
|
const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts");
|
|
const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts");
|
|
jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob);
|
|
// Federation jobs — registered only when federation is on.
|
|
if (process.env.FEDERATION_ENABLED === "true") {
|
|
const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts");
|
|
const { federationKvSweepJob } = await import("./app/jobs/federation-kv-sweep.ts");
|
|
const { deliverActivityJob } = await import("./app/jobs/deliver-activity.ts");
|
|
const { pollRemoteActorJob } = await import("./app/jobs/poll-remote-actor.ts");
|
|
const { pollRemoteOutboxesJob } = await import("./app/jobs/poll-remote-outboxes.ts");
|
|
jobs.push(backfillUserKeypairsJob, federationKvSweepJob, deliverActivityJob, pollRemoteActorJob, pollRemoteOutboxesJob);
|
|
}
|
|
|
|
const boss = createBoss(getDatabaseUrl());
|
|
await startWorker(boss, jobs);
|
|
// Register the started boss so feature code can enqueue jobs against
|
|
// the same instance via getBoss() / enqueueOptional().
|
|
const { setBoss } = await import("./app/lib/boss.server.ts");
|
|
setBoss(boss);
|
|
logger.info("Background job worker started");
|
|
|
|
// One-shot federation keypair backfill per startup (spec: existing
|
|
// users get keys before any federation traffic). Each run only
|
|
// touches users whose public_key IS NULL, so repeats are no-ops.
|
|
if (process.env.FEDERATION_ENABLED === "true") {
|
|
await boss.send("backfill-user-keypairs", {});
|
|
logger.info("federation keypair backfill enqueued");
|
|
}
|
|
});
|