import { Hono, type Context } from "hono"; import { cors } from "hono/cors"; import { secureHeaders } from "hono/secure-headers"; import { getRequestListener } from "@hono/node-server"; import { createServer } from "http"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { initializeRebaseBackend, installShutdownHandlers, // {{#frontend}} serveSPA, // {{/frontend}} HonoEnv, listenWithPortRetry, cleanupDevPortFile, loadDeclaredStorageSources, resolveStorageSources, logger } from "@rebasepro/server"; import { createPostgresDatabaseConnection, createPostgresAdapter } from "@rebasepro/server-postgres"; // {{#collections}} import { enums, relations, tables } from "./schema.generated.js"; // {{/collections}} import { storageAuthorize } from "../../config/storage.js"; import { env } from "./env.js"; // {{#collections}} import usersCollection from "../../config/collections/users.js"; // {{/collections}} const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Which buckets this project has, read from the `storage` block of its own // `rebase.json`. Declared there rather than here so the platform, the console // and this process all read one list — a custom image ships the repository, so // the file it already contains is the natural place for it. Absent means one // default source, configured from the plain S3_*/GCS_* variables. const storageSources = loadDeclaredStorageSources(__dirname); // ─── App ───────────────────────────────────────────────────────────── const app: Hono = new Hono(); const isProduction = env.NODE_ENV === "production"; const allowedOrigins = isProduction ? (() => { const origins = env.CORS_ORIGINS || env.FRONTEND_URL; if (!origins) { throw new Error( "CORS_ORIGINS or FRONTEND_URL must be set in production. " + "Example: CORS_ORIGINS=https://yourdomain.com" ); } return origins.split(",").map(s => s.trim()); })() : []; // In dev we still restrict which origins are reflected. Because `credentials` // is enabled, reflecting an arbitrary Origin would let any website the // developer happens to visit make credentialed cross-origin requests to this // dev server (and read the responses) using the developer's session. So dev // reflects only localhost origins; requests with no Origin (curl, same-origin) // are unaffected. const isLocalhostOrigin = (origin: string): boolean => { try { const { hostname } = new URL(origin); return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"; } catch { return false; } }; app.use("/*", cors({ origin: (origin) => { if (isProduction) return allowedOrigins.includes(origin) ? origin : null; if (!origin) return "*"; return isLocalhostOrigin(origin) ? origin : null; }, credentials: true })); app.use("/*", secureHeaders()); // ─── Database ──────────────────────────────────────────────────────── const databaseUrl = env.DATABASE_URL; const { db, pool, connectionString } = createPostgresDatabaseConnection(databaseUrl); // ─── Start ─────────────────────────────────────────────────────────── async function startServer() { const jwtSecret = env.JWT_SECRET; const PORT = env.PORT; const server = createServer(getRequestListener(app.fetch)); // `backend/crons` holds the scheduled jobs — nightly backups among them. // Passed only when the directory exists, because a configured `cronsDir` // with nothing in it mounts the cron routes and warns at every boot. // Sibling-relative like `functionsDir`: both compile alongside this file, so // the same path is right from source and from `backend/dist/backend/src`. const cronsDir = path.resolve(__dirname, "../crons"); const backend = await initializeRebaseBackend({ // {{#collections}} collectionsDir: path.resolve(__dirname, "../../config/collections"), // {{/collections}} // {{^collections}} // No `collectionsDir`: this project declares no collections in code, so // the server derives them from the live database schema at boot — // exactly what the managed runtime did for it. // {{/collections}} functionsDir: path.resolve(__dirname, "../functions"), cronsDir: fs.existsSync(cronsDir) ? cronsDir : undefined, server, app, database: createPostgresAdapter({ connection: db, // {{#collections}} schema: { tables, enums, relations }, // {{/collections}} adminConnectionString: env.ADMIN_CONNECTION_STRING || databaseUrl, connectionString }), auth: { // {{#collections}} collection: usersCollection, // {{/collections}} // {{^collections}} // No users collection is declared here, so auth falls back to its // own default users table — the same fallback a headless bundle gets. // {{/collections}} jwtSecret, accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN, refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN, serviceKey: env.REBASE_SERVICE_KEY, // Cookie-based auth: the refresh token is stored in an httpOnly // cookie (not readable by JS) instead of localStorage, so it is // not exposed to XSS. The frontend opts in via // `authFlowMode: "cookie"` on createRebaseClient. Requires CORS // `credentials: true` (set above). cookieAuth: { sameSite: "Lax" }, google: env.GOOGLE_CLIENT_ID ? { clientId: env.GOOGLE_CLIENT_ID } : undefined, allowRegistration: env.ALLOW_REGISTRATION, email: env.SMTP_HOST ? { from: env.SMTP_FROM || `${env.APP_NAME} `, smtp: { host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_SECURE, auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS! } : undefined, name: env.SMTP_NAME }, appName: env.APP_NAME, resetPasswordUrl: env.FRONTEND_URL } : undefined }, // File storage is opt-in. With no bucket configured, storage is OFF in // production — the upload routes answer 501 STORAGE_NOT_CONFIGURED — // rather than writing to the container filesystem, which is erased on // every restart and redeploy. Uploads that fail loudly are recoverable; // uploads that succeed into a disk about to be wiped are not. That rule // lives in the runtime, which drops a `local` backend in production // unless FORCE_LOCAL_STORAGE says a durable volume really is mounted. // // One resolver, shared with the managed runtime, so this entrypoint // cannot drift from it: every source declared in `rebase.json` is read // from `__` (S3_BUCKET__MEDIA for a source keyed "media"), // and a project that declared nothing gets one default source from the // plain, unsuffixed variables — exactly as before. storage: resolveStorageSources( process.env, storageSources, path.resolve(__dirname, "../../uploads") ), storageSources, // Storage is not under row-level security, so this hook IS its access // model — the server refuses to boot in production without one, because // "signed in" would otherwise be the only thing between a visitor and // every file in the bucket. See config/storage.ts, which also shows the // multi-tenant (per-owner) shape and the two escape hatches. storageAuthorize, history: true }); // ─── Your own routes ────────────────────────────────────────── // This is a plain Hono app and everything you add to it is yours — which // also means it is outside Rebase's auth. `initializeRebaseBackend` guards // the routers it mounts (`/api/data`, `/api/auth`, …); it does not guard // this `app`. A route added here is reachable by anyone on the internet // until you put a guard in its middleware slot: // // import { requireAuth, requireAdmin } from "@rebasepro/server"; // app.get("/admin/report", requireAuth, requireAdmin, handler); // // `requireAuth` answers 401 without a valid token; `requireAdmin` answers // 403 without the `admin` role and must follow `requireAuth`. Note that // `c.get("driver")` — the driver carrying the caller's identity — is only // set inside the Rebase routers, so out here reach for // `rebase.dataAsAdmin`. That one is **admin-scoped, not an RLS bypass**: it // runs as `{ uid: "service", roles: ["admin"] }` and your policies still // apply, evaluated against that identity — which is exactly an admin's // reach, and therefore belongs behind one of those guards. (`rebase.sql()` // is the real bypass: owner connection, no policies.) // ─── Health check ───────────────────────────────────────────── // Deliberately public: an orchestrator's probe has no token to send. // // Answered on both paths. `/health` is what an orchestrator probes and what // the generated `docker-compose.yml` already points at, so it stays. // `/api/health` is where a developer looks first, because every other route // this server has is under `/api` — and a reverse proxy that forwards only // `/api` to the backend can reach nothing else. const healthCheck = async (c: Context) => { const result = await backend.healthCheck(); const status = result.healthy ? 200 : 503; return c.json({ status: result.healthy ? "ok" : "degraded", latencyMs: result.latencyMs, ...(result.details ? { details: result.details } : {}) }, status); }; app.get("/health", healthCheck); app.get("/api/health", healthCheck); // {{#frontend}} // Serve the frontend in production. // // Four levels up, not two: in production this file runs compiled, from // `backend/dist/backend/src`. The paths above are the same in both modes // because `config/` is compiled alongside this file; `frontend/dist` is not // in the compiled tree at all, so it stays where the repository put it. // `serveSPA` only warns when the path is wrong, which is why the Dockerfile // that builds this image also copies `frontend` — verify a mount by // fetching `/`, never by reading the log. if (isProduction) { serveSPA(app, { frontendPath: path.resolve(__dirname, "../../../../frontend/dist") }); } // {{/frontend}} if (!isProduction) { // Dev mode: retry the next port if the current one is in use const projectRoot = path.resolve(__dirname, "../.."); const actualPort = await listenWithPortRetry(server, PORT, { portFileDir: projectRoot, serviceKey: env.REBASE_SERVICE_KEY }); // Clean up port file on exit const cleanup = () => cleanupDevPortFile(projectRoot); process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); process.on("exit", cleanup); logger.info(`Server running at http://localhost:${actualPort}`); } else { server.listen(PORT, () => { logger.info(`Server running at http://localhost:${PORT}`); }); } // ─── Graceful Shutdown ─────────────────────────────────────────────── // Drains HTTP, stops crons, tears down realtime, then closes the pool. // Guards against double signals and force-exits if shutdown hangs. installShutdownHandlers(backend, { onCleanup: () => pool.end() }); } startServer().catch(err => { logger.error("Failed to start server", { error: err instanceof Error ? err : new Error(String(err)) }); process.exit(1); }); export { app };