Wire discovery and probing for LNURL mints, add rate-limited POST /api/index for user submissions, and optionally announce confirmed state to relays. Co-authored-by: Cursor <cursoragent@cursor.com>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
import { serve } from '@hono/node-server';
|
|
import { announceLnurlMints } from './announce.ts';
|
|
import { config } from './config.ts';
|
|
import { closeDb, getDb, pruneProbes } from './db.ts';
|
|
import { closePool, runDiscovery } from './discovery.ts';
|
|
import { log } from './log.ts';
|
|
import { probeAll, probeUrls } from './probe.ts';
|
|
import { createApp } from './server.ts';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
let shuttingDown = false;
|
|
/** Set while a probe or discovery cycle is in flight, so shutdown can wait for it. */
|
|
let inFlight: Promise<unknown> = Promise.resolve();
|
|
|
|
function track<T>(work: Promise<T>): Promise<T> {
|
|
inFlight = inFlight.then(() => work).catch(() => undefined);
|
|
return work;
|
|
}
|
|
|
|
/*
|
|
* One cycle of each kind at a time. A cycle that outlives its interval (a relay socket
|
|
* that never closes, a wedged fetch) must not have the next timer tick start a second
|
|
* copy beside it — that doubles the relay load and interleaves state writes for no
|
|
* new information. The late cycle finishes; the ticks it swallowed are simply skipped.
|
|
*/
|
|
let probeRunning = false;
|
|
let discoveryRunning = false;
|
|
|
|
async function probeCycle(): Promise<void> {
|
|
if (shuttingDown || probeRunning) return;
|
|
probeRunning = true;
|
|
try {
|
|
await track(probeAll());
|
|
/*
|
|
* Announcing runs after probing, in the same cycle, because it publishes only what
|
|
* the probe just confirmed — running it on its own timer would mean signing for a
|
|
* state that could be an interval old. Off by default; see `announceConfig`.
|
|
*
|
|
* Its own failures are caught here rather than allowed to mark the probe cycle
|
|
* failed: whether relays accepted an event says nothing about whether this site
|
|
* successfully checked its mints.
|
|
*/
|
|
await track(announceLnurlMints()).catch((err: unknown) => {
|
|
log.error('announce cycle failed', {
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
});
|
|
});
|
|
} catch (err) {
|
|
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
|
|
} finally {
|
|
probeRunning = false;
|
|
}
|
|
}
|
|
|
|
async function discoveryCycle(backfill: boolean): Promise<void> {
|
|
if (shuttingDown || discoveryRunning) return;
|
|
discoveryRunning = true;
|
|
try {
|
|
const result = await track(runDiscovery(backfill));
|
|
// New mints are probed straight away, not on the next cycle, so their page has
|
|
// real metadata as soon as it is reachable.
|
|
if (result.newMints.length > 0) await track(probeUrls(result.newMints));
|
|
} catch (err) {
|
|
log.error('discovery cycle failed', {
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
});
|
|
} finally {
|
|
discoveryRunning = false;
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// Fail loudly here rather than on the first request: a bad DATABASE_URL, an
|
|
// unreachable Postgres or an unwritable SQLite directory should stop the boot.
|
|
await getDb();
|
|
|
|
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
|
|
log.info('api listening', {
|
|
port: info.port,
|
|
db: config.db.label,
|
|
relays: config.relays.length,
|
|
});
|
|
});
|
|
|
|
const probeTimer = setInterval(() => void probeCycle(), config.probeIntervalMin * 60 * 1000);
|
|
const discoveryTimer = setInterval(
|
|
() => void discoveryCycle(false),
|
|
config.discoveryIntervalMin * 60 * 1000,
|
|
);
|
|
const pruneTimer = setInterval(() => {
|
|
void pruneProbes()
|
|
.then((removed) => {
|
|
if (removed > 0) log.info('pruned probes', { rows: removed });
|
|
})
|
|
.catch((err: unknown) => {
|
|
log.error('prune failed', { reason: err instanceof Error ? err.message : String(err) });
|
|
});
|
|
}, DAY_MS);
|
|
|
|
// Startup: probe what we already know, then a full discovery backfill so a fresh
|
|
// database converges to complete review counts (BACKEND.md).
|
|
void probeCycle().then(() => discoveryCycle(true));
|
|
|
|
const shutdown = (signal: string) => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
log.info('shutting down', { signal });
|
|
|
|
clearInterval(probeTimer);
|
|
clearInterval(discoveryTimer);
|
|
clearInterval(pruneTimer);
|
|
|
|
const stop = (): void => {
|
|
// closeDb drains the Postgres pool, so it is awaited before the process leaves.
|
|
void closeDb().finally(() => process.exit(0));
|
|
};
|
|
|
|
void inFlight.finally(() => {
|
|
closePool();
|
|
server.close(stop);
|
|
// Do not hang forever on a relay socket that refuses to close.
|
|
setTimeout(stop, 8000).unref();
|
|
});
|
|
};
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
}
|
|
|
|
void main().catch((err: unknown) => {
|
|
log.error('startup failed', { reason: err instanceof Error ? err.message : String(err) });
|
|
process.exit(1);
|
|
});
|