diff --git a/apps/vps/package.json b/apps/vps/package.json index b435e9cd..8908a329 100644 --- a/apps/vps/package.json +++ b/apps/vps/package.json @@ -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", diff --git a/apps/vps/src/app.ts b/apps/vps/src/app.ts index b6d6dfc9..2b094c9d 100644 --- a/apps/vps/src/app.ts +++ b/apps/vps/src/app.ts @@ -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' @@ -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 @@ -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 }) @@ -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) => { + 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') @@ -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> +export type AppType = Awaited> -export default await initializeApp() +export default await initializeHonoApp() diff --git a/apps/vps/src/health.integration.test.ts b/apps/vps/src/health.integration.test.ts new file mode 100644 index 00000000..a8f92e04 --- /dev/null +++ b/apps/vps/src/health.integration.test.ts @@ -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 | 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 => response.json() + +const createServerWithDatabaseCheck = (check: Effect.Effect) => + 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 }) + }) +}) diff --git a/apps/vps/src/http/health.handlers.ts b/apps/vps/src/http/health.handlers.ts new file mode 100644 index 00000000..2ce276b2 --- /dev/null +++ b/apps/vps/src/http/health.handlers.ts @@ -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 +} + +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) diff --git a/apps/vps/src/http/routes.ts b/apps/vps/src/http/routes.ts new file mode 100644 index 00000000..70f6bce7 --- /dev/null +++ b/apps/vps/src/http/routes.ts @@ -0,0 +1,68 @@ +import { Api } from '@gbfm/api/api' +import { Effect, Layer } from 'effect' +import { + HttpMiddleware, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse +} from 'effect/unstable/http' +import { HttpApiBuilder, HttpApiScalar } from 'effect/unstable/httpapi' +import type { AppType } from '@/app' +import { corsConfig } from '@/lib/create-app' +import { HealthHandlersLive, makeHealthHandlers, type HealthDatabase } from './health.handlers' + +const isAllowedOrigin = (origin: string) => corsConfig.origin(origin) === origin + +const CorsLive = HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: isAllowedOrigin, + allowedMethods: corsConfig.allowMethods, + allowedHeaders: corsConfig.allowHeaders, + exposedHeaders: corsConfig.exposeHeaders, + credentials: corsConfig.credentials + }), + { global: true } +) + +const honoFallbackRoutes = (honoApp: AppType) => + HttpRouter.use((router) => + router.add('*', '/*', (request) => + HttpServerRequest.toWeb(request).pipe( + Effect.flatMap((webRequest) => + Effect.promise(() => Promise.resolve(honoApp.fetch(webRequest))) + ), + Effect.map(HttpServerResponse.fromWeb) + ) + ) + ) + +interface CreateWebHandlerOptions { + readonly healthDatabase?: HealthDatabase +} + +interface WebHandler { + readonly handler: (request: Request) => Promise + readonly dispose: () => Promise +} + +export const createWebHandler = ( + honoApp: AppType, + options: CreateWebHandlerOptions = {} +): WebHandler => { + const healthHandlers = options.healthDatabase + ? makeHealthHandlers(options.healthDatabase) + : HealthHandlersLive + + const apiRoutes = HttpApiBuilder.layer(Api, { openapiPath: '/openapi.json' }).pipe( + Layer.provide(healthHandlers) + ) + + const routes = Layer.mergeAll( + apiRoutes, + HttpApiScalar.layer(Api, { path: '/effect-reference' }), + honoFallbackRoutes(honoApp) + ).pipe(Layer.provide(CorsLive), Layer.provide(HttpServer.layerServices)) + + return HttpRouter.toWebHandler(routes, { disableLogger: true }) +} diff --git a/apps/vps/src/index.ts b/apps/vps/src/index.ts index 5cc347f2..f3fa1283 100644 --- a/apps/vps/src/index.ts +++ b/apps/vps/src/index.ts @@ -1,9 +1,15 @@ export const localVPSPort = 3003 -const { default: app } = await import('./app') +const { default: app, setupGracefulShutdown, startBackgroundEffects } = await import('./app') +const { createWebHandler } = await import('./http/routes') + +const webHandler = createWebHandler(app) + +setupGracefulShutdown(webHandler.dispose) +startBackgroundEffects() export default { port: localVPSPort, - fetch: app.fetch, + fetch: webHandler.handler, maxRequestBodySize: 1024 * 1024 * 1000 // 1GB } diff --git a/apps/vps/src/runtime/services.ts b/apps/vps/src/runtime/services.ts index a5e4424b..2e770eb0 100644 --- a/apps/vps/src/runtime/services.ts +++ b/apps/vps/src/runtime/services.ts @@ -1,4 +1,5 @@ import { Layer } from 'effect' +import { OtlpLive } from '@/lib/otel' import { MdxServiceLive } from '@/lib/mdx' import { DatabaseServiceLive } from '@/services/database.service' @@ -31,7 +32,13 @@ const DevToolsLive: Layer.Layer = Layer.empty const SentryClientLive = SentryClientServiceLive.pipe(Layer.provide(ConfigServiceLive)) +const ObservabilityLive = OtlpLive.pipe( + Layer.provide(ConfigServiceLive), + Layer.provide(SentryClientLive) +) + const BaseServicesLayer = Layer.mergeAll( + ObservabilityLive, ConfigServiceLive, DatabaseServiceLive, EmailServiceLive, diff --git a/apps/vps/src/services/sentry-client.service.ts b/apps/vps/src/services/sentry-client.service.ts index 4169db2a..1211d951 100644 --- a/apps/vps/src/services/sentry-client.service.ts +++ b/apps/vps/src/services/sentry-client.service.ts @@ -20,8 +20,8 @@ export const SentryClientServiceLive = Layer.effect( const enabled = shouldEnableSentry(sentry.dsn, sentry.environment) if (!enabled) { - yield* Effect.logWarning( - `[sentry] disabled (dsn=${sentry.dsn ? 'set' : 'missing'}, env=${sentry.environment}, set SENTRY_ENABLED=true to force)` + yield* Effect.logDebug( + `[sentry] disabled (dsn=${sentry.dsn ? 'set' : 'missing'}, env=${sentry.environment})` ) return { client: undefined, enabled: false } } diff --git a/bun.lock b/bun.lock index 89e8789f..3ea91dfa 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,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", @@ -185,6 +186,18 @@ "vitest": "4.1.5", }, }, + "packages/api": { + "name": "@gbfm/api", + "version": "0.0.1", + "dependencies": { + "effect": "4.0.0-beta.93", + }, + "devDependencies": { + "@types/bun": "1.3.13", + "typescript": "6.0.3", + "vitest": "4.1.8", + }, + }, "packages/core": { "name": "@gbfm/core", "version": "0.0.1", @@ -736,6 +749,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@gbfm/api": ["@gbfm/api@workspace:packages/api"], + "@gbfm/core": ["@gbfm/core@workspace:packages/core"], "@gbfm/email": ["@gbfm/email@workspace:packages/email"], diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 00000000..78b06112 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,22 @@ +{ + "name": "@gbfm/api", + "version": "0.0.1", + "type": "module", + "exports": { + "./api": "./src/api.ts", + "./errors": "./src/errors.ts", + "./health": "./src/health.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "effect": "4.0.0-beta.93" + }, + "devDependencies": { + "@types/bun": "1.3.13", + "typescript": "6.0.3", + "vitest": "4.1.8" + } +} diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts new file mode 100644 index 00000000..75c9f947 --- /dev/null +++ b/packages/api/src/api.ts @@ -0,0 +1,4 @@ +import { HttpApi } from 'effect/unstable/httpapi' +import { HealthGroup } from './health' + +export const Api = HttpApi.make('gbfm').add(HealthGroup) diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts new file mode 100644 index 00000000..df2c78af --- /dev/null +++ b/packages/api/src/errors.ts @@ -0,0 +1,11 @@ +import { Schema } from 'effect' + +export class ReadinessCheckFailedError extends Schema.TaggedErrorClass()( + 'ReadinessCheckFailedError', + { + dbConnected: Schema.Literal(false) + }, + { httpApiStatus: 500 } +) {} + +export const isReadinessCheckFailedError = Schema.is(ReadinessCheckFailedError) diff --git a/packages/api/src/health.ts b/packages/api/src/health.ts new file mode 100644 index 00000000..732cbd7a --- /dev/null +++ b/packages/api/src/health.ts @@ -0,0 +1,27 @@ +import { Schema } from 'effect' +import { HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi' +import { ReadinessCheckFailedError } from './errors' + +export const HealthLiveResponse = Schema.Struct({ + ok: Schema.Literal(true) +}) +export type HealthLiveResponse = typeof HealthLiveResponse.Type + +export const HealthReadyResponse = Schema.Struct({ + dbConnected: Schema.Literal(true) +}) +export type HealthReadyResponse = typeof HealthReadyResponse.Type + +export const HealthGroup = HttpApiGroup.make('health').add( + HttpApiEndpoint.get('live', '/health/live', { + success: HealthLiveResponse + }), + HttpApiEndpoint.get('ready', '/health/ready', { + success: HealthReadyResponse, + error: ReadinessCheckFailedError + }), + HttpApiEndpoint.get('check', '/health', { + success: HealthReadyResponse, + error: ReadinessCheckFailedError + }) +) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 00000000..43a84b5d --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './errors' +export * from './health' diff --git a/packages/api/sst-env.d.ts b/packages/api/sst-env.d.ts new file mode 100644 index 00000000..64441936 --- /dev/null +++ b/packages/api/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 00000000..fb601cfb --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "rewriteRelativeImportExtensions": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noImplicitOverride": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "noEmit": true, + "types": ["@types/bun"], + "plugins": [{ "name": "@effect/language-service" }] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/api-test/README.md b/scripts/api-test/README.md index 21a83eb2..2e73d9d0 100644 --- a/scripts/api-test/README.md +++ b/scripts/api-test/README.md @@ -21,6 +21,12 @@ bun run scripts/api-test/health.ts bun run scripts/api-test/health.ts -v # verbose ``` +Prints and enforces the shared curl-style assertions, for example: + +```bash +curl -fsS "http://127.0.0.1:3003/health" | jq -e '.dbConnected == true' +``` + ### redirect.ts Test share/redirect endpoints (`/s/*`). diff --git a/scripts/api-test/health.ts b/scripts/api-test/health.ts index 27239ed2..f5581e51 100644 --- a/scripts/api-test/health.ts +++ b/scripts/api-test/health.ts @@ -1,6 +1,63 @@ -import { apiGet, colors, header, parseArgs, printResponse, separator, API_URL } from './lib/common' +import { isDeepStrictEqual } from 'node:util' +import { + HealthLiveResponse as HealthLiveResponseSchema, + HealthReadyResponse as HealthReadyResponseSchema, + type HealthLiveResponse, + type HealthReadyResponse +} from '@gbfm/api/health' +import { Effect, Schema } from 'effect' +import { API_URL, colors, header, parseArgs, printResponse, separator } from './lib/common' -const { GREEN, RED } = colors +const { DIM, GREEN, RED } = colors + +type HealthAssertionPath = '/health/live' | '/health/ready' | '/health' +type HealthAssertionBody = HealthLiveResponse | HealthReadyResponse + +interface HealthAssertion { + readonly name: string + readonly method: 'GET' + readonly path: HealthAssertionPath + readonly expectedStatus: 200 + readonly expectedBody: HealthAssertionBody + readonly curl: string +} + +interface HealthAssertionResult { + readonly status: number + readonly headers: Headers + readonly bodyText: string + readonly body: HealthAssertionBody +} + +const healthLiveBody: HealthLiveResponse = { ok: true } +const healthReadyBody: HealthReadyResponse = { dbConnected: true } + +const makeHealthAssertions = (baseUrl: string): readonly HealthAssertion[] => [ + { + name: 'GET /health/live returns liveness JSON', + method: 'GET', + path: '/health/live', + expectedStatus: 200, + expectedBody: healthLiveBody, + curl: `${curlGet(baseUrl, '/health/live')} | jq -e '.ok == true'` + }, + { + name: 'GET /health/ready returns readiness JSON', + method: 'GET', + path: '/health/ready', + expectedStatus: 200, + expectedBody: healthReadyBody, + curl: `${curlGet(baseUrl, '/health/ready')} | jq -e '.dbConnected == true'` + }, + { + name: 'GET /health returns readiness JSON', + method: 'GET', + path: '/health', + expectedStatus: 200, + expectedBody: healthReadyBody, + curl: `${curlGet(baseUrl, '/health')} | jq -e '.dbConnected == true'` + } +] const { verbose, help } = parseArgs(Bun.argv.slice(2)) @@ -18,18 +75,94 @@ Environment: process.exit(0) } -header(`Health Check: ${API_URL}/health`) +let healthy = true -const response = await apiGet('/health') +for (const assertion of makeHealthAssertions(API_URL)) { + header(assertion.name) + console.log(`${DIM}${assertion.curl}${colors.NC}`) + console.log('') -printResponse(response, verbose) + try { + const result = await runHealthAssertion(assertion) + printResponse( + { + status: result.status, + headers: result.headers, + body: result.bodyText + }, + verbose + ) + console.log('') + console.log(`${GREEN}✓ Assertion passed${colors.NC}`) + } catch (error) { + healthy = false + console.log(`${RED}✗ Assertion failed${colors.NC}`) + console.log(error instanceof Error ? error.message : String(error)) + } -separator() + separator() +} -if (response.status === 200) { - console.log(`${GREEN}✓ API is healthy${colors.NC}`) - process.exit(0) -} else { - console.log(`${RED}✗ API health check failed${colors.NC}`) - process.exit(1) +process.exit(healthy ? 0 : 1) + +async function runHealthAssertion(assertion: HealthAssertion): Promise { + const response = await fetch( + new Request(new URL(assertion.path, API_URL).toString(), { method: assertion.method }) + ) + const bodyText = await response.text() + + if (response.status !== assertion.expectedStatus) { + throw new Error( + `${assertion.curl} expected status ${assertion.expectedStatus} but received ${response.status}: ${bodyText}` + ) + } + + const rawBody = parseJsonBody(bodyText, assertion) + const body = await Effect.runPromise(decodeHealthBody(assertion.path, rawBody)) + + if (!isDeepStrictEqual(body, assertion.expectedBody)) { + throw new Error( + `${assertion.curl} expected body ${JSON.stringify(assertion.expectedBody)} but received ${JSON.stringify(body)}` + ) + } + + return { + status: response.status, + headers: response.headers, + bodyText, + body + } +} + +function decodeHealthBody( + path: HealthAssertionPath, + body: unknown +): Effect.Effect { + switch (path) { + case '/health/live': + return Schema.decodeUnknownEffect(HealthLiveResponseSchema)(body) + case '/health/ready': + case '/health': + return Schema.decodeUnknownEffect(HealthReadyResponseSchema)(body) + } +} + +function parseJsonBody(bodyText: string, assertion: HealthAssertion): unknown { + try { + return JSON.parse(bodyText) + } catch (cause) { + throw new Error(`${assertion.curl} returned non-JSON body`, { cause }) + } +} + +function curlGet(baseUrl: string, path: HealthAssertionPath) { + return `curl -fsS ${shellQuote(new URL(path, baseUrl).toString())}` +} + +function shellQuote(value: string) { + return `"${value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('$', '\\$') + .replaceAll('`', '\\`')}"` }