Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/vps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@effect/opentelemetry": "4.0.0-beta.93",
"@effect/platform": "0.96.2",
"@effect/platform-bun": "4.0.0-beta.93",
"@gbfm/api": "workspace:*",
"@gbfm/core": "workspace:*",
"@gbfm/email": "workspace:*",
"@hono/zod-openapi": "1.3.0",
Expand Down
76 changes: 20 additions & 56 deletions apps/vps/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { sql } from 'drizzle-orm'
import { Data, Duration, Effect, Schedule } from 'effect'
import type { Context } from 'hono'
import { Duration, Effect, Schedule } from 'effect'
import configureOpenAPI from '@/lib/configure-open-api'
import { createAppEffect } from '@/lib/create-app'
import admin from '@/routes/admin/admin.index'
Expand All @@ -23,26 +21,13 @@ import upload from '@/routes/upload/upload.index'
import uploadMultipart from '@/routes/upload-multipart/upload-multipart.index'
import betterAuthRoutes from '@/routes/user/better-auth.routes'
import user from '@/routes/user/user.index'
import { db } from './db'
import { regenerateSitemap } from './routes/redirect/seo/sitemap'
import { runApp, runAppFork } from './runtime'
import { processPendingReminders, queryNextDueReminder } from './services/reminder-processor'
import { ReminderSignalService } from './services/reminder-signal.service'
import { SentryService } from './services/sentry.service'

class HealthCheckError extends Data.TaggedError('HealthCheckError')<{
cause?: unknown
}> {}

const healthCheckEffect = Effect.tryPromise({
try: () => db.execute(sql.raw('SELECT 1')),
catch: (cause) => new HealthCheckError({ cause })
})

const READINESS_CACHE_MS = 5_000
let readinessCache: { checkedAt: number; status: 200 | 500 } | undefined

const setupRoutesEffect = Effect.gen(function* () {
export const honoAppEffect = Effect.gen(function* () {
yield* SentryService
const app = yield* createAppEffect

Expand All @@ -66,38 +51,11 @@ const setupRoutesEffect = Effect.gen(function* () {
app.route('/api/music', music)
app.route('/api/music-reminders', musicReminders)

// Kept at root, not under /api: auth has its own basePath/email links, and these are externally-referenced public URLs.
app.route('/auth', betterAuthRoutes)
app.route('/s', shareRouter)
app.route('', rss)
app.route('', seoRouter)

app.get('/health/live', (c) => c.json({ ok: true }, 200))

const readinessHealthRoute = async (c: Context) => {
const cache = readinessCache
const cachedStatus = cache && Date.now() - cache.checkedAt < READINESS_CACHE_MS

if (cachedStatus) {
return c.json({ dbConnected: cache.status === 200 }, cache.status)
}

const program = healthCheckEffect.pipe(
Effect.map(() => ({ data: { dbConnected: true }, status: 200 as const })),
Effect.catch(() => Effect.succeed({ data: { dbConnected: false }, status: 500 as const }))
)

const result = await runApp(program)
readinessCache = {
checkedAt: Date.now(),
status: result.status
}
return c.json(result.data, result.status)
}

app.get('/health/ready', readinessHealthRoute)
app.get('/health', readinessHealthRoute)

return app
})

Expand Down Expand Up @@ -133,13 +91,21 @@ const sitemapRegenerationEffect = regenerateSitemap.pipe(
Effect.repeat(Schedule.spaced('1 hours'))
)

const mainEffect = setupRoutesEffect
let gracefulShutdownConfigured = false

export const setupGracefulShutdown = (disposeHttp?: () => Promise<void>) => {
if (gracefulShutdownConfigured) return

gracefulShutdownConfigured = true

const setupGracefulShutdown = () => {
const shutdown = async (signal: string) => {
console.log(`Graceful shutdown initiated via ${signal}`)

try {
if (disposeHttp) {
await disposeHttp()
}

const { disposeRuntime } = await import('./runtime')
await disposeRuntime()
console.log('Runtime disposed successfully')
Expand All @@ -157,21 +123,19 @@ const setupGracefulShutdown = () => {
process.on('SIGINT', () => shutdown('SIGINT'))
}

// Initialize app with Effect
const initializeApp = async () => {
setupGracefulShutdown()

runAppFork(reminderLoopEffect)
runAppFork(sitemapRegenerationEffect)
export const startBackgroundEffects = () => {
void runAppFork(reminderLoopEffect)
void runAppFork(sitemapRegenerationEffect)
}

const initializeHonoApp = async () => {
return await runApp(
mainEffect.pipe(
Effect.tap(() => Effect.log('App initialized successfully')),
honoAppEffect.pipe(
Effect.tapError((error) => Effect.logError(`❌ Failed to initialize app: ${error}`))
)
)
}

export type AppType = Awaited<ReturnType<typeof initializeApp>>
export type AppType = Awaited<ReturnType<typeof initializeHonoApp>>

export default await initializeApp()
export default await initializeHonoApp()
162 changes: 162 additions & 0 deletions apps/vps/src/health.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { ReadinessCheckFailedError } from '@gbfm/api/errors'
import { Effect } from 'effect'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AppType } from '@/app'
import { createWebHandler } from '@/http/routes'

const baseUrl = 'http://127.0.0.1:3003'

let honoApp: AppType
let liveServer: ReturnType<typeof createWebHandler> | undefined

const liveFetch = (request: Request) => {
if (!liveServer) {
throw new Error('Live server is not initialized')
}

return liveServer.handler(request)
}

const healthRequest = (path: string, init?: RequestInit) =>
new Request(new URL(path, baseUrl).toString(), init)

const readJson = async (response: Response): Promise<unknown> => response.json()

const createServerWithDatabaseCheck = (check: Effect.Effect<void, ReadinessCheckFailedError>) =>
createWebHandler(honoApp, {
healthDatabase: { check }
})

const expectJsonResponse = async (response: Response, status: number, body: unknown) => {
expect(response.status).toBe(status)
expect(await readJson(response)).toEqual(body)
}

afterAll(async () => {
if (liveServer) {
await liveServer.dispose()
}
})

beforeAll(async () => {
const mod = await import('@/app')
honoApp = mod.default
liveServer = createWebHandler(honoApp)
})

describe('Health endpoints', () => {
it('GET /health/live returns liveness JSON without checking the database', async () => {
let checks = 0
const server = createServerWithDatabaseCheck(
Effect.sync(() => {
checks += 1
})
)

try {
await expectJsonResponse(await server.handler(healthRequest('/health/live')), 200, {
ok: true
})
expect(checks).toBe(0)
} finally {
await server.dispose()
}
})

it('GET /health/ready checks the configured database seam', async () => {
let checks = 0
const server = createServerWithDatabaseCheck(
Effect.sync(() => {
checks += 1
})
)

try {
await expectJsonResponse(await server.handler(healthRequest('/health/ready')), 200, {
dbConnected: true
})
expect(checks).toBe(1)
} finally {
await server.dispose()
}
})

it('GET /health returns the same readiness result', async () => {
let checks = 0
const server = createServerWithDatabaseCheck(
Effect.sync(() => {
checks += 1
})
)

try {
await expectJsonResponse(await server.handler(healthRequest('/health')), 200, {
dbConnected: true
})
expect(checks).toBe(1)
} finally {
await server.dispose()
}
})

it('serves readiness from the 5s cache on repeated successful calls', async () => {
let checks = 0
const server = createServerWithDatabaseCheck(
Effect.sync(() => {
checks += 1
})
)

try {
const first = await server.handler(healthRequest('/health/ready'))
const second = await server.handler(healthRequest('/health/ready'))

await expectJsonResponse(first, 200, { dbConnected: true })
await expectJsonResponse(second, 200, { dbConnected: true })
expect(checks).toBe(1)
} finally {
await server.dispose()
}
})

it('caches readiness failures with a 500 response', async () => {
let checks = 0
const server = createServerWithDatabaseCheck(
Effect.sync(() => {
checks += 1
}).pipe(
Effect.flatMap(() => Effect.fail(new ReadinessCheckFailedError({ dbConnected: false })))
)
)

try {
const first = await server.handler(healthRequest('/health/ready'))
const second = await server.handler(healthRequest('/health/ready'))

await expectJsonResponse(first, 500, {
_tag: 'ReadinessCheckFailedError',
dbConnected: false
})
await expectJsonResponse(second, 500, {
_tag: 'ReadinessCheckFailedError',
dbConnected: false
})
expect(checks).toBe(1)
} finally {
await server.dispose()
}
})

it('responds 404 to unsupported methods on health paths', async () => {
const res = await liveFetch(healthRequest('/health/live', { method: 'POST' }))

expect(res.status).toBe(404)
})

it('uses the live Postgres connection for readiness in integration', async () => {
const response = await liveFetch(healthRequest('/health/ready'))

expect(response.status).toBe(200)
expect(await readJson(response)).toEqual({ dbConnected: true })
})
})
73 changes: 73 additions & 0 deletions apps/vps/src/http/health.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Api } from '@gbfm/api/api'
import { ReadinessCheckFailedError } from '@gbfm/api/errors'
import type { HealthLiveResponse, HealthReadyResponse } from '@gbfm/api/health'
import { sql } from 'drizzle-orm'
import { Clock, Effect } from 'effect'
import { HttpApiBuilder } from 'effect/unstable/httpapi'
import { db } from '@/db'

const READINESS_CACHE_MS = 5_000

type ReadinessStatus = 200 | 500
type ReadinessCache = { readonly checkedAt: number; readonly status: ReadinessStatus }

export interface HealthDatabase {
readonly check: Effect.Effect<void, ReadinessCheckFailedError>
}

const liveResponse = (): HealthLiveResponse => ({ ok: true })
const readyResponse = (): HealthReadyResponse => ({ dbConnected: true })

const readinessFailure = () => new ReadinessCheckFailedError({ dbConnected: false })

const readinessCacheValue = (checkedAt: number, status: ReadinessStatus): ReadinessCache => ({
checkedAt,
status
})

const cachedReadinessResponse = (status: ReadinessStatus) =>
status === 200 ? Effect.succeed(readyResponse()) : Effect.fail(readinessFailure())

export const HealthDatabaseLive: HealthDatabase = {
check: Effect.tryPromise({
try: () => db.execute(sql.raw('SELECT 1')),
catch: () => readinessFailure()
}).pipe(Effect.asVoid)
}

const makeReadinessResponse = (healthDatabase: HealthDatabase) => {
let readinessCache: ReadinessCache | undefined

return Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const cache = readinessCache

if (cache && now - cache.checkedAt < READINESS_CACHE_MS) {
return yield* cachedReadinessResponse(cache.status)
}

return yield* Effect.matchEffect(healthDatabase.check, {
onFailure: (error) =>
Effect.sync(() => {
readinessCache = readinessCacheValue(now, 500)
}).pipe(Effect.flatMap(() => Effect.fail(error))),
onSuccess: () =>
Effect.sync(() => {
readinessCache = readinessCacheValue(now, 200)
}).pipe(Effect.as(readyResponse()))
})
})
}

export const makeHealthHandlers = (healthDatabase: HealthDatabase) => {
const readinessResponse = makeReadinessResponse(healthDatabase)

return HttpApiBuilder.group(Api, 'health', (handlers) =>
handlers
.handle('live', () => Effect.succeed(liveResponse()))
.handle('ready', () => readinessResponse)
.handle('check', () => readinessResponse)
)
}

export const HealthHandlersLive = makeHealthHandlers(HealthDatabaseLive)
Loading