- Sync now pulls every BTC Map place worldwide (~29k live), not just Madeira — dropped BTCMAP_CENTER_LAT/LON/RADIUS_KM entirely. - Incremental via a stored watermark (settings.btcmap_synced_since): only fetches what changed since the last sync, not the whole dataset every time. Handles deletions too (BTC Map delistings correctly evict the local place + cascade its claim/links, not just go unclaimed). - Real bug caught and fixed before it shipped: tried BTC Map's own recommended updated_since+limit pagination first, but their timestamps mix millisecond and whole-second precision, and naive string comparison across that isn't chronologically safe — silently truncated a real sync to ~4,000 of ~42,000 records with no error. Measured the alternative (single unpaginated request) instead: 2-3s for the full dataset, simpler and actually correct. Full writeup in services/btcmap.ts, regression test for the specific bug in services/btcmap.test.ts. - @fastify/compress added — the places list is now tens of thousands of rows, gzip/br/zstd auto-negotiated. - Also fixed while touching dependencies: @fastify/static had a real high-severity path-traversal/auth-bypass advisory (GHSA-pr96-94w5-mx2h et al) affecting the version we were pinned to — bumped to the patched 10.1.2. - Frontend: leaflet.markercluster (raw per-marker rendering doesn't scale to tens of thousands of points), cyberpunk-themed cluster icons to match the existing HUD styling, batch marker insertion (addLayers, not a per-marker addLayer loop) since that's dramatically faster at this scale. Default map view is now world-scale, recentering on the player's location if geolocation is available. - 60 backend tests passing throughout (7 new for the sync rewrite).
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import compress from '@fastify/compress';
|
|
import cookie from '@fastify/cookie';
|
|
import cors from '@fastify/cors';
|
|
import fastifyStatic from '@fastify/static';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { Config } from './config.js';
|
|
import { openDatabase, type DB } from './db/database.js';
|
|
import { BlockHeightService, type BlockHeightProvider } from './services/blockHeight.js';
|
|
import nostrAuth from './plugins/nostr-auth.js';
|
|
import authRoutes from './routes/auth.js';
|
|
import placesRoutes from './routes/places.js';
|
|
import claimsRoutes from './routes/claims.js';
|
|
import linksRoutes from './routes/links.js';
|
|
import fieldsRoutes from './routes/fields.js';
|
|
import syncRoutes from './routes/sync.js';
|
|
|
|
export interface AppContext {
|
|
config: Config;
|
|
db: DB;
|
|
blockHeight: BlockHeightProvider;
|
|
}
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyInstance {
|
|
ctx: AppContext;
|
|
}
|
|
}
|
|
|
|
export interface BuildAppOptions {
|
|
config: Config;
|
|
dbPath?: string; // override for tests (':memory:')
|
|
logger?: boolean;
|
|
blockHeight?: BlockHeightProvider; // override for tests — avoids real network calls
|
|
}
|
|
|
|
export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance> {
|
|
const { config } = opts;
|
|
const db = openDatabase(opts.dbPath ?? join(config.DATA_DIR, 'regress.sqlite3'));
|
|
|
|
const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 1 * 1024 * 1024 });
|
|
|
|
const ctx: AppContext = {
|
|
config,
|
|
db,
|
|
blockHeight: opts.blockHeight ?? new BlockHeightService(config.BLOCK_HEIGHT_API_URL),
|
|
};
|
|
app.decorate('ctx', ctx);
|
|
|
|
await app.register(cookie);
|
|
await app.register(cors, { origin: true, credentials: true, methods: ['GET', 'POST', 'DELETE'] });
|
|
// Global place list is tens of thousands of rows — gzip is the single
|
|
// biggest win for that payload size, worth having by default everywhere.
|
|
await app.register(compress, { global: true });
|
|
await app.register(nostrAuth, { db, config });
|
|
|
|
// Everything below is mounted under ROUTE_PREFIX (empty by default, i.e.
|
|
// root) so a single build can be deployed either standalone or under a
|
|
// shared domain's path — see config.ts for why the prefix must be
|
|
// preserved end-to-end rather than stripped by the reverse proxy.
|
|
const prefix = config.ROUTE_PREFIX;
|
|
await app.register(
|
|
async (scoped) => {
|
|
scoped.get('/api/health', async () => ({ status: 'ok' }));
|
|
|
|
await scoped.register(authRoutes);
|
|
await scoped.register(placesRoutes);
|
|
await scoped.register(claimsRoutes);
|
|
await scoped.register(linksRoutes);
|
|
await scoped.register(fieldsRoutes);
|
|
await scoped.register(syncRoutes);
|
|
|
|
const staticDir = config.STATIC_DIR;
|
|
if (staticDir && existsSync(staticDir)) {
|
|
await scoped.register(fastifyStatic, { root: staticDir, prefix: '/' });
|
|
scoped.setNotFoundHandler((req, reply) => {
|
|
if (req.raw.url?.startsWith(`${prefix}/api/`)) {
|
|
return reply.code(404).send({ error: 'not found' });
|
|
}
|
|
return reply.sendFile('index.html');
|
|
});
|
|
}
|
|
},
|
|
{ prefix },
|
|
);
|
|
|
|
app.addHook('onClose', async () => {
|
|
db.close();
|
|
});
|
|
|
|
return app;
|
|
}
|