import { randomUUID } from "node:crypto"; import type { HttpBindings } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { type DeviceDTO, HealthResponse } from "@pneumo/protocol"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import type { Logger } from "pino"; import packageJson from "../package.json" with { type: "json" }; import { createAuthRoutes } from "./auth/routes.js"; import type { Config } from "./config.js"; import type { DeviceRegistry } from "./devices/registry.js"; import { createDeviceRoutes } from "./devices/routes.js"; import type { FileRegistry } from "./files/registry.js"; import { createFileRoutes } from "./files/routes.js"; import { errorResponse, handleError } from "./http/errors.js"; import { securityHeaders, staticCacheControl } from "./http/headers.js"; import type { PushIndex } from "./index-store/index.js"; import type { InstanceState } from "./instance/state.js"; import { createRequestLogger } from "./log.js"; import { createPairingRoutes } from "./pairing/routes.js"; import { createPushRoutes } from "./pushes/routes.js"; import type { Storage } from "./storage/types.js"; import type { UploadMode } from "./storage/upload-mode.js"; export interface Clock { /** Returns Unix epoch milliseconds. */ now(): number; } export const systemClock: Clock = { now: () => Date.now() }; export interface AppDependencies { config: Config; storage: Storage; clock: Clock; logger: Logger; instanceState: InstanceState; devices: DeviceRegistry; pushes: PushIndex; files: FileRegistry; uploadMode: UploadMode; vapidPublicKey: string; } export type AppEnv = { Bindings: HttpBindings; Variables: { logger: Logger; requestId: string; device: DeviceDTO; staticPath?: string; }; }; export function createApp(deps: AppDependencies) { const app = new Hono(); app.onError(handleError); app.notFound((c) => errorResponse(c, "not_found")); app.use("*", async (c, next) => { const requestId = randomUUID(); const logger = createRequestLogger(deps.logger, requestId); c.set("requestId", requestId); c.set("logger", logger); c.header("X-Request-Id", requestId); const startedAt = deps.clock.now(); await next(); logger.info( { method: c.req.method, path: c.req.path, status: c.res.status, ms: Math.max(0, deps.clock.now() - startedAt), }, "Request completed", ); }); app.use( "*", securityHeaders( deps.config.PUBLIC_URL, deps.uploadMode === "direct" ? deps.storage.bucketOrigin : undefined, ), ); const limitJson = bodyLimit({ maxSize: 128 * 1024, onError: (c) => errorResponse(c, "payload_too_large"), }); app.use("*", async (c, next) => { if (c.req.method === "PUT" && /^\/api\/files\/[^/]+\/content$/u.test(c.req.path)) { return next(); } const mediaType = c.req.header("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); if (mediaType === "application/json" || mediaType?.endsWith("+json")) { return limitJson(c, next); } await next(); }); app.get("/api/health", (c) => c.json( HealthResponse.parse({ ok: true, version: packageJson.version, initialized: deps.instanceState.initialized, }), ), ); app.route("/api", createAuthRoutes(deps)); app.route("/api", createDeviceRoutes(deps)); app.route("/api", createPairingRoutes(deps)); app.route("/api", createFileRoutes(deps)); app.route("/api", createPushRoutes(deps)); app.all("/api", (c) => errorResponse(c, "not_found")); app.all("/api/*", (c) => errorResponse(c, "not_found")); app.use("*", async (c, next) => { await next(); const path = c.get("staticPath"); if (path) c.header("Cache-Control", staticCacheControl(path)); }); app.use( "*", serveStatic({ root: deps.config.WEB_DIST_DIR, onFound: (path, c) => c.set("staticPath", path), }), ); app.get( "*", serveStatic({ root: deps.config.WEB_DIST_DIR, path: "index.html", onFound: (path, c) => c.set("staticPath", path), }), ); return app; }