Files
sync-server-v2/source/index.ts
T
2026-09-14 08:00:08 +00:00

118 lines
4.2 KiB
TypeScript

import { Config } from './services/config.ts';
import { Database, MigrationService } from './services/storage/index.ts';
import { AuthSecp256k1 } from './auth/auth.ts';
import { Broadcaster } from './services/broadcaster.ts';
import { ApplicationRouter } from './services/router.ts';
import { HttpTransportRouter } from './services/transport/http-transport.ts';
import { WsTransportRouter } from './services/transport/ws-transport.ts';
import { ServerHost } from './services/server-host.ts';
import { Logger } from './utils/logger.ts';
import { DataRoute } from './routes/resources.ts';
/** Application composition root. */
export class App {
/**
* Construct infrastructure and validate all routes before returning a runnable app.
*/
static async create(): Promise<App> {
const config = Config.fromEnv();
const debug = new Logger('sync-server-v2');
debug('config loaded: %O', config);
// Persistence must be ready before routes accept traffic.
const database = new Database({ path: config.database.path, debug });
const migrations = new MigrationService(database, debug);
await migrations.migrateToLatest();
// Create an Auth instance that can be passed in for signature validation
const auth = await AuthSecp256k1.create({ database }, { timestampWindowMs: config.auth.timestampWindowMs });
// Domain services are shared across all transports and route modules.
const broadcaster = new Broadcaster(debug);
const routes = [
// DataRoute owns resource read/write/subscribe logic and maps resource
// ids to broadcaster topics. timestampWindowMs controls write replay protection.
new DataRoute({
auth: auth,
database: database,
broadcaster: broadcaster,
}, {
timestampWindowMs: config.auth.timestampWindowMs,
}),
];
// Route loading is an explicit startup phase, not first-request work.
// ApplicationRouter.create validates every path and rejects duplicates
// before any client can connect.
const router = await ApplicationRouter.create({ auth }, routes);
// Both transports share one ApplicationRouter. Routes use the shared
// Broadcaster directly, while HTTP and WebSocket remain protocol adapters.
const http = new HttpTransportRouter(router, debug);
const ws = new WsTransportRouter(router, debug, config.server.maxRequestBodyBytes);
const host = new ServerHost(config, debug, [ http, ws ]);
return new App(host, database);
}
private stopPromise: Promise<void> | undefined;
constructor(
private readonly host: ServerHost,
private readonly database: Database,
) {}
async start(): Promise<void> {
await this.database.start();
await this.host.start();
}
/** Stop transports before releasing the database they may still use. */
async stop(): Promise<void> {
this.stopPromise ??= (async (): Promise<void> => {
try {
await this.host.stop();
} finally {
await this.database.destroy();
}
})();
await this.stopPromise;
}
startUniqueRequestCleanup(cleanupIntervalMs: number, timestampWindowMs: number): void {
// Every 10 seconds, we will cleanup the requests table
setInterval(async () => {
await this.database.db
.deleteFrom('authed_requests')
.where('timestamp', '<', Date.now() - timestampWindowMs)
.execute();
}, cleanupIntervalMs);
}
}
const app = await App.create();
let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals): void => {
if (shuttingDown) {
return;
}
shuttingDown = true;
void app.stop().catch((error) => {
console.error('Graceful shutdown failed after ' + signal, error);
process.exitCode = 1;
});
};
process.once('SIGINT', (): void => shutdown('SIGINT'));
process.once('SIGTERM', (): void => shutdown('SIGTERM'));
try {
await app.start();
} catch (error: unknown) {
await app.stop();
throw error;
}