From 7f05736586193f42ec7133da024d4f865f7c3235 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Mon, 6 Jul 2026 09:11:12 +0100 Subject: [PATCH 01/10] feat(api): scaffold @gbfm/api with Effect HttpApi health contract Step 1 of the Hono -> Effect HttpApi migration (docs/migration-effect-http-api.md). - New leaf workspace package packages/api so server-only code cannot leak into the www client bundle. Depends only on effect (4.0.0-beta.93). - Health group contract: live (/health/live), ready (/health/ready), and check (/health) endpoints with response schemas preserving the current Hono behavior (live -> { ok: true }, ready/check -> { dbConnected: true }). - Shared ReadinessCheckFailedError (Schema.TaggedErrorClass, httpApiStatus 500) for the readiness 500 path; first of the Phase 2 error ports. - Contract unit tests (packages/api) verify endpoint shape, schema decode rejects, error tagging/encoding, and the typed HttpApiClient interface. - Behavior-preserving integration test (apps/vps) characterizes the current Hono health endpoints as a migration safety net (status, body, 5s cache, rate-limit exclusion) before the serving topology changes in Step 2. --- apps/vps/src/health.integration.test.ts | 49 ++++++++++++++ bun.lock | 14 ++++ package.json | 2 +- packages/api/package.json | 22 ++++++ packages/api/src/api.ts | 4 ++ packages/api/src/errors.ts | 11 +++ packages/api/src/health.ts | 27 ++++++++ packages/api/src/index.ts | 3 + packages/api/test/health.contract.test.ts | 81 +++++++++++++++++++++++ packages/api/tsconfig.json | 25 +++++++ 10 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 apps/vps/src/health.integration.test.ts create mode 100644 packages/api/package.json create mode 100644 packages/api/src/api.ts create mode 100644 packages/api/src/errors.ts create mode 100644 packages/api/src/health.ts create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/test/health.contract.test.ts create mode 100644 packages/api/tsconfig.json diff --git a/apps/vps/src/health.integration.test.ts b/apps/vps/src/health.integration.test.ts new file mode 100644 index 00000000..ea59023a --- /dev/null +++ b/apps/vps/src/health.integration.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import type { AppType } from '@/app' + +let app: AppType + +beforeAll(async () => { + const mod = await import('@/app') + app = mod.default +}) + +describe('Health endpoints (behavior baseline)', () => { + it('GET /health/live returns 200 with { ok: true }', async () => { + const res = await app.request('/health/live') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('GET /health/ready returns 200 with { dbConnected: true } when the DB is reachable', async () => { + const res = await app.request('/health/ready') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ dbConnected: true }) + }) + + it('GET /health is an alias of /health/ready', async () => { + const res = await app.request('/health') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ dbConnected: true }) + }) + + it('serves readiness from the 5s cache on repeated calls (same status, no extra DB work)', async () => { + const first = await app.request('/health/ready') + const second = await app.request('/health/ready') + expect(first.status).toBe(second.status) + expect(await first.json()).toEqual(await second.json()) + }) + + it('does not rate-limit health probes even under rapid fire', async () => { + const statuses: number[] = [] + for (let i = 0; i < 80; i++) { + statuses.push((await app.request('/health/live')).status) + } + expect(statuses.every((s) => s === 200)).toBe(true) + }) + + it('responds 404 to unsupported methods on health paths', async () => { + const res = await app.request('/health/live', { method: 'POST' }) + expect(res.status).toBe(404) + }) +}) diff --git a/bun.lock b/bun.lock index 89e8789f..e065d17b 100644 --- a/bun.lock +++ b/bun.lock @@ -185,6 +185,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 +748,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/package.json b/package.json index 4bf680c8..241c0b12 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "gen": "cd vps && sst shell drizzle-kit generate", "release": "semantic-release", "typecheck": "tsgo --noEmit && bun --filter='*' typecheck", - "test": "cd packages/core && bun run test && cd ../theme && bun run test && cd ../../apps/vps && bun run test", + "test": "cd packages/api && bun run test && cd ../core && bun run test && cd ../theme && bun run test && cd ../../apps/vps && bun run test", "lint": "oxlint apps/*/src packages infra '*.{js,ts,tsx,jsx}' --no-error-on-unmatched-pattern", "lint:fix": "oxlint apps/*/src packages infra '*.{js,ts,tsx,jsx}' --fix --no-error-on-unmatched-pattern", "format": "oxfmt apps/*/src packages infra '*.{js,ts,tsx,jsx,json,css}' --write --no-error-on-unmatched-pattern", 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/test/health.contract.test.ts b/packages/api/test/health.contract.test.ts new file mode 100644 index 00000000..3dd139a1 --- /dev/null +++ b/packages/api/test/health.contract.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' +import { Effect, Schema } from 'effect' +import { HttpApiClient } from 'effect/unstable/httpapi' +import { Api } from '../src/api' +import { isReadinessCheckFailedError, ReadinessCheckFailedError } from '../src/errors' +import { HealthGroup, HealthLiveResponse, HealthReadyResponse } from '../src/health' + +describe('Health group contract', () => { + test('declares live, ready, and check endpoints with root paths', () => { + const { live, ready, check } = HealthGroup.endpoints + expect(live).toBeDefined() + expect(ready).toBeDefined() + expect(check).toBeDefined() + if (!live || !ready || !check) throw new Error('missing health endpoint') + + expect(live.method).toBe('GET') + expect(live.path).toBe('/health/live') + expect(ready.method).toBe('GET') + expect(ready.path).toBe('/health/ready') + expect(check.method).toBe('GET') + expect(check.path).toBe('/health') + }) + + test('live endpoint declares no error', () => { + const live = HealthGroup.endpoints.live + expect(Array.from(live?.error ?? [])).toEqual([]) + }) + + test('ready and check declare ReadinessCheckFailedError as their error schema', () => { + for (const name of ['ready', 'check'] as const) { + const errorSchemas = Array.from(HealthGroup.endpoints[name]?.error ?? []) + expect(errorSchemas).toHaveLength(1) + const err = new ReadinessCheckFailedError({ dbConnected: false }) + expect(Schema.is(ReadinessCheckFailedError)(err)).toBe(true) + expect(err._tag).toBe('ReadinessCheckFailedError') + } + }) + + test('live success decodes { ok: true } and rejects { ok: false }', () => { + const bad: unknown = { ok: false } + expect(Schema.decodeSync(HealthLiveResponse)({ ok: true })).toEqual({ ok: true }) + expect(() => Schema.decodeUnknownSync(HealthLiveResponse)(bad)).toThrow() + }) + + test('ready success decodes { dbConnected: true } and rejects false', () => { + const bad: unknown = { dbConnected: false } + expect(Schema.decodeSync(HealthReadyResponse)({ dbConnected: true })).toEqual({ + dbConnected: true + }) + expect(() => Schema.decodeUnknownSync(HealthReadyResponse)(bad)).toThrow() + }) + + test('ReadinessCheckFailedError is tagged, carries dbConnected: false, and encodes with _tag', () => { + const err = new ReadinessCheckFailedError({ dbConnected: false }) + expect(err._tag).toBe('ReadinessCheckFailedError') + expect(err.dbConnected).toBe(false) + expect(isReadinessCheckFailedError(err)).toBe(true) + expect(Schema.encodeSync(ReadinessCheckFailedError)(err)).toMatchObject({ + _tag: 'ReadinessCheckFailedError', + dbConnected: false + }) + }) + + test('Api composes the health group', () => { + expect(Object.keys(Api.groups).sort()).toEqual(['health']) + expect(Api.groups.health).toBe(HealthGroup) + }) + + test('HttpApiClient.ForApi exposes a typed health group with live/ready/check callables', () => { + type Client = HttpApiClient.ForApi + type Health = Client['health'] + type IsCallable = Health[K] extends ( + ...args: never + ) => Effect.Effect + ? true + : false + type Proof = [IsCallable<'live'>, IsCallable<'ready'>, IsCallable<'check'>] + const proof: Proof = [true, true, true] + expect(proof).toEqual([true, true, true]) + }) +}) 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"] +} From c779bdce6a1ca87590ab59db438ceddbff0324a8 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Mon, 6 Jul 2026 11:00:08 +0100 Subject: [PATCH 02/10] refactor(api): drop redundant contract test, keep curl baseline The packages/api contract test duplicated the vps health integration test at the boundary (e.g. { ok: true } decodes) and the rest was internal-shape noise (endpoint names, schema internals, error encoding). User-facing behavior parity belongs at the HTTP boundary, which the vps curl-style integration test already captures as the migration baseline. Step 2 will run the same request->response spec against the Effect HttpApiBuilder server to prove parity without duplicating assertions. --- package.json | 2 +- packages/api/test/health.contract.test.ts | 81 ----------------------- 2 files changed, 1 insertion(+), 82 deletions(-) delete mode 100644 packages/api/test/health.contract.test.ts diff --git a/package.json b/package.json index 241c0b12..4bf680c8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "gen": "cd vps && sst shell drizzle-kit generate", "release": "semantic-release", "typecheck": "tsgo --noEmit && bun --filter='*' typecheck", - "test": "cd packages/api && bun run test && cd ../core && bun run test && cd ../theme && bun run test && cd ../../apps/vps && bun run test", + "test": "cd packages/core && bun run test && cd ../theme && bun run test && cd ../../apps/vps && bun run test", "lint": "oxlint apps/*/src packages infra '*.{js,ts,tsx,jsx}' --no-error-on-unmatched-pattern", "lint:fix": "oxlint apps/*/src packages infra '*.{js,ts,tsx,jsx}' --fix --no-error-on-unmatched-pattern", "format": "oxfmt apps/*/src packages infra '*.{js,ts,tsx,jsx,json,css}' --write --no-error-on-unmatched-pattern", diff --git a/packages/api/test/health.contract.test.ts b/packages/api/test/health.contract.test.ts deleted file mode 100644 index 3dd139a1..00000000 --- a/packages/api/test/health.contract.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { Effect, Schema } from 'effect' -import { HttpApiClient } from 'effect/unstable/httpapi' -import { Api } from '../src/api' -import { isReadinessCheckFailedError, ReadinessCheckFailedError } from '../src/errors' -import { HealthGroup, HealthLiveResponse, HealthReadyResponse } from '../src/health' - -describe('Health group contract', () => { - test('declares live, ready, and check endpoints with root paths', () => { - const { live, ready, check } = HealthGroup.endpoints - expect(live).toBeDefined() - expect(ready).toBeDefined() - expect(check).toBeDefined() - if (!live || !ready || !check) throw new Error('missing health endpoint') - - expect(live.method).toBe('GET') - expect(live.path).toBe('/health/live') - expect(ready.method).toBe('GET') - expect(ready.path).toBe('/health/ready') - expect(check.method).toBe('GET') - expect(check.path).toBe('/health') - }) - - test('live endpoint declares no error', () => { - const live = HealthGroup.endpoints.live - expect(Array.from(live?.error ?? [])).toEqual([]) - }) - - test('ready and check declare ReadinessCheckFailedError as their error schema', () => { - for (const name of ['ready', 'check'] as const) { - const errorSchemas = Array.from(HealthGroup.endpoints[name]?.error ?? []) - expect(errorSchemas).toHaveLength(1) - const err = new ReadinessCheckFailedError({ dbConnected: false }) - expect(Schema.is(ReadinessCheckFailedError)(err)).toBe(true) - expect(err._tag).toBe('ReadinessCheckFailedError') - } - }) - - test('live success decodes { ok: true } and rejects { ok: false }', () => { - const bad: unknown = { ok: false } - expect(Schema.decodeSync(HealthLiveResponse)({ ok: true })).toEqual({ ok: true }) - expect(() => Schema.decodeUnknownSync(HealthLiveResponse)(bad)).toThrow() - }) - - test('ready success decodes { dbConnected: true } and rejects false', () => { - const bad: unknown = { dbConnected: false } - expect(Schema.decodeSync(HealthReadyResponse)({ dbConnected: true })).toEqual({ - dbConnected: true - }) - expect(() => Schema.decodeUnknownSync(HealthReadyResponse)(bad)).toThrow() - }) - - test('ReadinessCheckFailedError is tagged, carries dbConnected: false, and encodes with _tag', () => { - const err = new ReadinessCheckFailedError({ dbConnected: false }) - expect(err._tag).toBe('ReadinessCheckFailedError') - expect(err.dbConnected).toBe(false) - expect(isReadinessCheckFailedError(err)).toBe(true) - expect(Schema.encodeSync(ReadinessCheckFailedError)(err)).toMatchObject({ - _tag: 'ReadinessCheckFailedError', - dbConnected: false - }) - }) - - test('Api composes the health group', () => { - expect(Object.keys(Api.groups).sort()).toEqual(['health']) - expect(Api.groups.health).toBe(HealthGroup) - }) - - test('HttpApiClient.ForApi exposes a typed health group with live/ready/check callables', () => { - type Client = HttpApiClient.ForApi - type Health = Client['health'] - type IsCallable = Health[K] extends ( - ...args: never - ) => Effect.Effect - ? true - : false - type Proof = [IsCallable<'live'>, IsCallable<'ready'>, IsCallable<'check'>] - const proof: Proof = [true, true, true] - expect(proof).toEqual([true, true, true]) - }) -}) From 3840ad732fbe3b94cd33923d91e7ca50d2eb112f Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Mon, 6 Jul 2026 14:27:14 +0200 Subject: [PATCH 03/10] test(api): add health curl assertions --- apps/vps/package.json | 2 + bun.lock | 17 +++ packages/api-test/package.json | 22 +++ packages/api-test/src/health.remote.test.ts | 47 ++++++ packages/api-test/src/health.test.ts | 53 +++++++ packages/api-test/src/health.ts | 155 ++++++++++++++++++++ packages/api-test/tsconfig.json | 25 ++++ scripts/api-test/README.md | 6 + scripts/api-test/health.ts | 47 ++++-- 9 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 packages/api-test/package.json create mode 100644 packages/api-test/src/health.remote.test.ts create mode 100644 packages/api-test/src/health.test.ts create mode 100644 packages/api-test/src/health.ts create mode 100644 packages/api-test/tsconfig.json diff --git a/apps/vps/package.json b/apps/vps/package.json index b435e9cd..bc05cb17 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", @@ -71,6 +72,7 @@ }, "devDependencies": { "@effect/language-service": "0.86.4", + "@gbfm/api-test": "workspace:*", "@testcontainers/postgresql": "^11.14.0", "@types/aws-lambda": "8.10.161", "@types/bun": "1.3.13", diff --git a/bun.lock b/bun.lock index e065d17b..330f4c54 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", @@ -113,6 +114,7 @@ }, "devDependencies": { "@effect/language-service": "0.86.4", + "@gbfm/api-test": "workspace:*", "@testcontainers/postgresql": "^11.14.0", "@types/aws-lambda": "8.10.161", "@types/bun": "1.3.13", @@ -197,6 +199,19 @@ "vitest": "4.1.8", }, }, + "packages/api-test": { + "name": "@gbfm/api-test", + "version": "0.0.1", + "dependencies": { + "@gbfm/api": "workspace:*", + "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", @@ -750,6 +765,8 @@ "@gbfm/api": ["@gbfm/api@workspace:packages/api"], + "@gbfm/api-test": ["@gbfm/api-test@workspace:packages/api-test"], + "@gbfm/core": ["@gbfm/core@workspace:packages/core"], "@gbfm/email": ["@gbfm/email@workspace:packages/email"], diff --git a/packages/api-test/package.json b/packages/api-test/package.json new file mode 100644 index 00000000..a2dad44e --- /dev/null +++ b/packages/api-test/package.json @@ -0,0 +1,22 @@ +{ + "name": "@gbfm/api-test", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + "./health": "./src/health.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@gbfm/api": "workspace:*", + "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-test/src/health.remote.test.ts b/packages/api-test/src/health.remote.test.ts new file mode 100644 index 00000000..cc95705f --- /dev/null +++ b/packages/api-test/src/health.remote.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + API_URL, + makeHealthAssertions, + runHealthAssertion, + type HealthAssertionPath +} from './health' + +const configuredBaseUrl = process.env.GBFM_API_URL +const testBaseUrl = configuredBaseUrl ?? API_URL +const describeRemote = configuredBaseUrl ? describe : describe.skip + +const assertionFor = (path: HealthAssertionPath) => { + const assertion = makeHealthAssertions(testBaseUrl).find((item) => item.path === path) + + if (!assertion) { + throw new Error(`Missing health assertion for ${path}`) + } + + return assertion +} + +describeRemote('VPS health API curl assertions', () => { + it('curl -fsS "$GBFM_API_URL/health/live" | jq -e \'.ok == true\'', async () => { + const assertion = assertionFor('/health/live') + const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) + }) + + it('curl -fsS "$GBFM_API_URL/health/ready" | jq -e \'.dbConnected == true\'', async () => { + const assertion = assertionFor('/health/ready') + const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) + }) + + it('curl -fsS "$GBFM_API_URL/health" | jq -e \'.dbConnected == true\'', async () => { + const assertion = assertionFor('/health') + const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) + }) +}) diff --git a/packages/api-test/src/health.test.ts b/packages/api-test/src/health.test.ts new file mode 100644 index 00000000..910f00c8 --- /dev/null +++ b/packages/api-test/src/health.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + makeHealthAssertions, + runHealthAssertions, + type ApiFetch, + type HealthAssertionBody +} from './health' + +const baseUrl = 'http://127.0.0.1:3003' + +describe('health assertions', () => { + it('exposes curl commands for each health endpoint', () => { + const assertions = makeHealthAssertions(baseUrl) + + expect(assertions.map((assertion) => assertion.curl)).toEqual([ + 'curl -fsS "http://127.0.0.1:3003/health/live" | jq -e \'.ok == true\'', + 'curl -fsS "http://127.0.0.1:3003/health/ready" | jq -e \'.dbConnected == true\'', + 'curl -fsS "http://127.0.0.1:3003/health" | jq -e \'.dbConnected == true\'' + ]) + }) + + it('runs the assertions through an injected fetch seam', async () => { + const paths: string[] = [] + const fetchHealth: ApiFetch = (request) => { + const path = new URL(request.url).pathname + paths.push(path) + + return Response.json(bodyForPath(path)) + } + + const results = await runHealthAssertions({ baseUrl, fetch: fetchHealth }) + + expect(paths).toEqual(['/health/live', '/health/ready', '/health']) + expect(results.map((result) => result.status)).toEqual([200, 200, 200]) + expect(results.map((result) => result.body)).toEqual([ + { ok: true }, + { dbConnected: true }, + { dbConnected: true } + ]) + }) +}) + +function bodyForPath(path: string): HealthAssertionBody { + switch (path) { + case '/health/live': + return { ok: true } + case '/health/ready': + case '/health': + return { dbConnected: true } + default: + throw new Error(`Unexpected health path: ${path}`) + } +} diff --git a/packages/api-test/src/health.ts b/packages/api-test/src/health.ts new file mode 100644 index 00000000..5ae91942 --- /dev/null +++ b/packages/api-test/src/health.ts @@ -0,0 +1,155 @@ +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' + +export const API_URL = process.env.GBFM_API_URL ?? 'http://127.0.0.1:3003' + +export type ApiFetch = (request: Request) => Promise | Response + +export type HealthAssertionPath = '/health/live' | '/health/ready' | '/health' + +export type HealthAssertionBody = HealthLiveResponse | HealthReadyResponse + +export interface HealthAssertion { + readonly name: string + readonly method: 'GET' + readonly path: HealthAssertionPath + readonly expectedStatus: 200 + readonly expectedBody: HealthAssertionBody + readonly curl: string +} + +export interface HealthAssertionOptions { + readonly baseUrl?: string + readonly fetch?: ApiFetch +} + +export interface HealthAssertionResult { + readonly assertion: HealthAssertion + readonly status: number + readonly headers: Headers + readonly bodyText: string + readonly body: HealthAssertionBody +} + +const healthLiveBody: HealthLiveResponse = { ok: true } +const healthReadyBody: HealthReadyResponse = { dbConnected: true } + +export function makeHealthAssertions(baseUrl = API_URL): readonly HealthAssertion[] { + return [ + { + 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'` + } + ] +} + +export async function runHealthAssertion( + assertion: HealthAssertion, + options: HealthAssertionOptions = {} +): Promise { + const baseUrl = options.baseUrl ?? API_URL + const send = options.fetch ?? fetchRequest + const response = await send( + new Request(new URL(assertion.path, baseUrl).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 { + assertion, + status: response.status, + headers: response.headers, + bodyText, + body + } +} + +export async function runHealthAssertions( + options: HealthAssertionOptions = {} +): Promise { + const baseUrl = options.baseUrl ?? API_URL + const results: HealthAssertionResult[] = [] + + for (const assertion of makeHealthAssertions(baseUrl)) { + results.push(await runHealthAssertion(assertion, options)) + } + + return results +} + +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 fetchRequest(request: Request) { + return globalThis.fetch(request) +} + +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('`', '\\`')}"` +} diff --git a/packages/api-test/tsconfig.json b/packages/api-test/tsconfig.json new file mode 100644 index 00000000..4f55ea1a --- /dev/null +++ b/packages/api-test/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/**/*"], + "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..60381853 100644 --- a/scripts/api-test/health.ts +++ b/scripts/api-test/health.ts @@ -1,6 +1,7 @@ -import { apiGet, colors, header, parseArgs, printResponse, separator, API_URL } from './lib/common' +import { makeHealthAssertions, runHealthAssertion } from '@gbfm/api-test/health' +import { API_URL, colors, header, parseArgs, printResponse, separator } from './lib/common' -const { GREEN, RED } = colors +const { DIM, GREEN, RED } = colors const { verbose, help } = parseArgs(Bun.argv.slice(2)) @@ -18,18 +19,32 @@ Environment: process.exit(0) } -header(`Health Check: ${API_URL}/health`) - -const response = await apiGet('/health') - -printResponse(response, verbose) - -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) +let healthy = true + +for (const assertion of makeHealthAssertions(API_URL)) { + header(assertion.name) + console.log(`${DIM}${assertion.curl}${colors.NC}`) + console.log('') + + try { + const result = await runHealthAssertion(assertion, { baseUrl: API_URL }) + 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() } + +process.exit(healthy ? 0 : 1) From ca729808b97b7479f1870d4de24a8e3fcaae183c Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Mon, 6 Jul 2026 14:27:39 +0200 Subject: [PATCH 04/10] feat(vps): serve health through Effect HttpApi --- apps/vps/src/app.ts | 78 +++++------------- apps/vps/src/health.integration.test.ts | 81 ++++++++++++++----- apps/vps/src/http/health.handlers.ts | 50 ++++++++++++ apps/vps/src/http/routes.ts | 65 +++++++++++++++ apps/vps/src/index.ts | 10 ++- apps/vps/src/lib/auth.ts | 12 +++ .../vps/src/routes/user/better-auth.routes.ts | 14 +--- 7 files changed, 216 insertions(+), 94 deletions(-) create mode 100644 apps/vps/src/http/health.handlers.ts create mode 100644 apps/vps/src/http/routes.ts diff --git a/apps/vps/src/app.ts b/apps/vps/src/app.ts index b6d6dfc9..fa611d03 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' @@ -21,28 +19,14 @@ import shows from '@/routes/shows/show.index' import spotify from '@/routes/spotify/spotify.index' 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 +50,10 @@ 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 +89,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 +121,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 index ea59023a..6752629e 100644 --- a/apps/vps/src/health.integration.test.ts +++ b/apps/vps/src/health.integration.test.ts @@ -1,35 +1,70 @@ +import { + makeHealthAssertions, + runHealthAssertion, + type HealthAssertionPath +} from '@gbfm/api-test/health' import { beforeAll, describe, expect, it } from 'vitest' -import type { AppType } from '@/app' -let app: AppType +type VpsServer = typeof import('@/index').default + +const baseUrl = 'http://127.0.0.1:3003' + +let server: VpsServer + +const serverFetch = (request: Request) => server.fetch(request) + +const assertionFor = (path: HealthAssertionPath) => { + const assertion = makeHealthAssertions(baseUrl).find((item) => item.path === path) + + if (!assertion) { + throw new Error(`Missing health assertion for ${path}`) + } + + return assertion +} beforeAll(async () => { - const mod = await import('@/app') - app = mod.default + const mod = await import('@/index') + server = mod.default }) -describe('Health endpoints (behavior baseline)', () => { - it('GET /health/live returns 200 with { ok: true }', async () => { - const res = await app.request('/health/live') - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ ok: true }) +describe('Health endpoints (Effect HttpApi through VPS server)', () => { + it('curl -fsS "http://127.0.0.1:3003/health/live" | jq -e \'.ok == true\'', async () => { + const assertion = assertionFor('/health/live') + const result = await runHealthAssertion(assertion, { + baseUrl, + fetch: serverFetch + }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) }) - it('GET /health/ready returns 200 with { dbConnected: true } when the DB is reachable', async () => { - const res = await app.request('/health/ready') - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ dbConnected: true }) + it('curl -fsS "http://127.0.0.1:3003/health/ready" | jq -e \'.dbConnected == true\'', async () => { + const assertion = assertionFor('/health/ready') + const result = await runHealthAssertion(assertion, { + baseUrl, + fetch: serverFetch + }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) }) - it('GET /health is an alias of /health/ready', async () => { - const res = await app.request('/health') - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ dbConnected: true }) + it('curl -fsS "http://127.0.0.1:3003/health" | jq -e \'.dbConnected == true\'', async () => { + const assertion = assertionFor('/health') + const result = await runHealthAssertion(assertion, { + baseUrl, + fetch: serverFetch + }) + + expect(result.status).toBe(assertion.expectedStatus) + expect(result.body).toEqual(assertion.expectedBody) }) it('serves readiness from the 5s cache on repeated calls (same status, no extra DB work)', async () => { - const first = await app.request('/health/ready') - const second = await app.request('/health/ready') + const first = await serverFetch(new Request(new URL('/health/ready', baseUrl).toString())) + const second = await serverFetch(new Request(new URL('/health/ready', baseUrl).toString())) expect(first.status).toBe(second.status) expect(await first.json()).toEqual(await second.json()) }) @@ -37,13 +72,17 @@ describe('Health endpoints (behavior baseline)', () => { it('does not rate-limit health probes even under rapid fire', async () => { const statuses: number[] = [] for (let i = 0; i < 80; i++) { - statuses.push((await app.request('/health/live')).status) + statuses.push( + (await serverFetch(new Request(new URL('/health/live', baseUrl).toString()))).status + ) } expect(statuses.every((s) => s === 200)).toBe(true) }) it('responds 404 to unsupported methods on health paths', async () => { - const res = await app.request('/health/live', { method: 'POST' }) + const res = await serverFetch( + new Request(new URL('/health/live', baseUrl).toString(), { method: 'POST' }) + ) expect(res.status).toBe(404) }) }) diff --git a/apps/vps/src/http/health.handlers.ts b/apps/vps/src/http/health.handlers.ts new file mode 100644 index 00000000..266ff852 --- /dev/null +++ b/apps/vps/src/http/health.handlers.ts @@ -0,0 +1,50 @@ +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 { Effect } from 'effect' +import { HttpApiBuilder } from 'effect/unstable/httpapi' +import { db } from '@/db' + +const READINESS_CACHE_MS = 5_000 + +let readinessCache: { checkedAt: number; status: 200 | 500 } | undefined + +const healthCheckEffect = Effect.tryPromise({ + try: () => db.execute(sql.raw('SELECT 1')), + catch: () => new ReadinessCheckFailedError({ dbConnected: false }) +}) + +const liveResponse = (): HealthLiveResponse => ({ ok: true }) +const readyResponse = (): HealthReadyResponse => ({ dbConnected: true }) + +const readinessFailure = () => new ReadinessCheckFailedError({ dbConnected: false }) + +const cachedReadinessResponse = (status: 200 | 500) => + status === 200 ? Effect.succeed(readyResponse()) : Effect.fail(readinessFailure()) + +const readinessResponse = Effect.gen(function* () { + const cache = readinessCache + + if (cache && Date.now() - cache.checkedAt < READINESS_CACHE_MS) { + return yield* cachedReadinessResponse(cache.status) + } + + return yield* Effect.matchEffect(healthCheckEffect, { + onFailure: (error) => + Effect.sync(() => { + readinessCache = { checkedAt: Date.now(), status: 500 } + }).pipe(Effect.flatMap(() => Effect.fail(error))), + onSuccess: () => + Effect.sync(() => { + readinessCache = { checkedAt: Date.now(), status: 200 } + }).pipe(Effect.as(readyResponse())) + }) +}) + +export const HealthHandlers = HttpApiBuilder.group(Api, 'health', (handlers) => + handlers + .handle('live', () => Effect.succeed(liveResponse())) + .handle('ready', () => readinessResponse) + .handle('check', () => readinessResponse) +) diff --git a/apps/vps/src/http/routes.ts b/apps/vps/src/http/routes.ts new file mode 100644 index 00000000..1d77e5d3 --- /dev/null +++ b/apps/vps/src/http/routes.ts @@ -0,0 +1,65 @@ +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 { auth, prepareAuthRequest } from '@/lib/auth' +import { corsConfig } from '@/lib/create-app' +import { HealthHandlers } 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 BetterAuthRoutes = HttpRouter.use((router) => + router.add('*', '/auth/*', (request) => + HttpServerRequest.toWeb(request).pipe( + Effect.flatMap((webRequest) => + Effect.promise(() => auth.handler(prepareAuthRequest(webRequest))) + ), + Effect.map(HttpServerResponse.fromWeb) + ) + ) +) + +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) + ) + ) + ) + +export const createWebHandler = (honoApp: AppType) => { + const apiRoutes = HttpApiBuilder.layer(Api, { openapiPath: '/openapi.json' }).pipe( + Layer.provide(HealthHandlers) + ) + + const routes = Layer.mergeAll( + apiRoutes, + HttpApiScalar.layer(Api, { path: '/effect-reference' }), + BetterAuthRoutes, + 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/lib/auth.ts b/apps/vps/src/lib/auth.ts index 1c87ab5a..1016a290 100644 --- a/apps/vps/src/lib/auth.ts +++ b/apps/vps/src/lib/auth.ts @@ -179,4 +179,16 @@ export const auth = betterAuth({ ] }) +export function prepareAuthRequest(request: Request) { + const hasOrigin = request.headers.has('origin') || request.headers.has('referer') + + if (request.method === 'POST' && !hasOrigin && request.headers.has('cookie')) { + const headers = new Headers(request.headers) + headers.delete('cookie') + return new Request(request, { headers }) + } + + return request +} + export type AuthSession = typeof auth.$Infer.Session diff --git a/apps/vps/src/routes/user/better-auth.routes.ts b/apps/vps/src/routes/user/better-auth.routes.ts index 86b6a148..d379c354 100644 --- a/apps/vps/src/routes/user/better-auth.routes.ts +++ b/apps/vps/src/routes/user/better-auth.routes.ts @@ -1,20 +1,8 @@ import { Hono } from 'hono' -import { auth } from '@/lib/auth' +import { auth, prepareAuthRequest } from '@/lib/auth' const betterAuthApp = new Hono() -function prepareAuthRequest(request: Request) { - const hasOrigin = request.headers.has('origin') || request.headers.has('referer') - - if (request.method === 'POST' && !hasOrigin && request.headers.has('cookie')) { - const headers = new Headers(request.headers) - headers.delete('cookie') - return new Request(request, { headers }) - } - - return request -} - betterAuthApp.on(['POST', 'GET'], '*', async (c) => { return auth.handler(prepareAuthRequest(c.req.raw)) }) From 7872b861a288fb4ab33012adfa3ced7237c8c01a Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:41:17 +0200 Subject: [PATCH 05/10] refactor(vps): make health handlers injectable and drop Effect-based auth routes --- apps/vps/src/app.ts | 2 + apps/vps/src/health.integration.test.ts | 188 ++++++++++++------ apps/vps/src/http/health.handlers.ts | 83 +++++--- apps/vps/src/http/routes.ts | 35 ++-- apps/vps/src/lib/auth.ts | 12 -- .../vps/src/routes/user/better-auth.routes.ts | 14 +- 6 files changed, 218 insertions(+), 116 deletions(-) diff --git a/apps/vps/src/app.ts b/apps/vps/src/app.ts index fa611d03..2b094c9d 100644 --- a/apps/vps/src/app.ts +++ b/apps/vps/src/app.ts @@ -19,6 +19,7 @@ import shows from '@/routes/shows/show.index' import spotify from '@/routes/spotify/spotify.index' 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 { regenerateSitemap } from './routes/redirect/seo/sitemap' import { runApp, runAppFork } from './runtime' @@ -50,6 +51,7 @@ export const honoAppEffect = Effect.gen(function* () { app.route('/api/music', music) app.route('/api/music-reminders', musicReminders) + app.route('/auth', betterAuthRoutes) app.route('/s', shareRouter) app.route('', rss) app.route('', seoRouter) diff --git a/apps/vps/src/health.integration.test.ts b/apps/vps/src/health.integration.test.ts index 6752629e..a8f92e04 100644 --- a/apps/vps/src/health.integration.test.ts +++ b/apps/vps/src/health.integration.test.ts @@ -1,88 +1,162 @@ -import { - makeHealthAssertions, - runHealthAssertion, - type HealthAssertionPath -} from '@gbfm/api-test/health' -import { beforeAll, describe, expect, it } from 'vitest' - -type VpsServer = typeof import('@/index').default +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 server: VpsServer +let honoApp: AppType +let liveServer: ReturnType | undefined -const serverFetch = (request: Request) => server.fetch(request) +const liveFetch = (request: Request) => { + if (!liveServer) { + throw new Error('Live server is not initialized') + } -const assertionFor = (path: HealthAssertionPath) => { - const assertion = makeHealthAssertions(baseUrl).find((item) => item.path === path) + return liveServer.handler(request) +} - if (!assertion) { - throw new Error(`Missing health assertion for ${path}`) - } +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 } + }) - return assertion +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('@/index') - server = mod.default + const mod = await import('@/app') + honoApp = mod.default + liveServer = createWebHandler(honoApp) }) -describe('Health endpoints (Effect HttpApi through VPS server)', () => { - it('curl -fsS "http://127.0.0.1:3003/health/live" | jq -e \'.ok == true\'', async () => { - const assertion = assertionFor('/health/live') - const result = await runHealthAssertion(assertion, { - baseUrl, - fetch: serverFetch - }) +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 + }) + ) - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) + try { + await expectJsonResponse(await server.handler(healthRequest('/health/live')), 200, { + ok: true + }) + expect(checks).toBe(0) + } finally { + await server.dispose() + } }) - it('curl -fsS "http://127.0.0.1:3003/health/ready" | jq -e \'.dbConnected == true\'', async () => { - const assertion = assertionFor('/health/ready') - const result = await runHealthAssertion(assertion, { - baseUrl, - fetch: serverFetch - }) + it('GET /health/ready checks the configured database seam', async () => { + let checks = 0 + const server = createServerWithDatabaseCheck( + Effect.sync(() => { + checks += 1 + }) + ) - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) + try { + await expectJsonResponse(await server.handler(healthRequest('/health/ready')), 200, { + dbConnected: true + }) + expect(checks).toBe(1) + } finally { + await server.dispose() + } }) - it('curl -fsS "http://127.0.0.1:3003/health" | jq -e \'.dbConnected == true\'', async () => { - const assertion = assertionFor('/health') - const result = await runHealthAssertion(assertion, { - baseUrl, - fetch: serverFetch - }) + it('GET /health returns the same readiness result', async () => { + let checks = 0 + const server = createServerWithDatabaseCheck( + Effect.sync(() => { + checks += 1 + }) + ) - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) + 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 calls (same status, no extra DB work)', async () => { - const first = await serverFetch(new Request(new URL('/health/ready', baseUrl).toString())) - const second = await serverFetch(new Request(new URL('/health/ready', baseUrl).toString())) - expect(first.status).toBe(second.status) - expect(await first.json()).toEqual(await second.json()) + 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('does not rate-limit health probes even under rapid fire', async () => { - const statuses: number[] = [] - for (let i = 0; i < 80; i++) { - statuses.push( - (await serverFetch(new Request(new URL('/health/live', baseUrl).toString()))).status + 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() } - expect(statuses.every((s) => s === 200)).toBe(true) }) it('responds 404 to unsupported methods on health paths', async () => { - const res = await serverFetch( - new Request(new URL('/health/live', baseUrl).toString(), { method: 'POST' }) - ) + 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 index 266ff852..2ce276b2 100644 --- a/apps/vps/src/http/health.handlers.ts +++ b/apps/vps/src/http/health.handlers.ts @@ -2,49 +2,72 @@ 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 { Effect } from 'effect' +import { Clock, Effect } from 'effect' import { HttpApiBuilder } from 'effect/unstable/httpapi' import { db } from '@/db' const READINESS_CACHE_MS = 5_000 -let readinessCache: { checkedAt: number; status: 200 | 500 } | undefined +type ReadinessStatus = 200 | 500 +type ReadinessCache = { readonly checkedAt: number; readonly status: ReadinessStatus } -const healthCheckEffect = Effect.tryPromise({ - try: () => db.execute(sql.raw('SELECT 1')), - catch: () => new ReadinessCheckFailedError({ dbConnected: false }) -}) +export interface HealthDatabase { + readonly check: Effect.Effect +} const liveResponse = (): HealthLiveResponse => ({ ok: true }) const readyResponse = (): HealthReadyResponse => ({ dbConnected: true }) const readinessFailure = () => new ReadinessCheckFailedError({ dbConnected: false }) -const cachedReadinessResponse = (status: 200 | 500) => +const readinessCacheValue = (checkedAt: number, status: ReadinessStatus): ReadinessCache => ({ + checkedAt, + status +}) + +const cachedReadinessResponse = (status: ReadinessStatus) => status === 200 ? Effect.succeed(readyResponse()) : Effect.fail(readinessFailure()) -const readinessResponse = Effect.gen(function* () { - const cache = readinessCache - - if (cache && Date.now() - cache.checkedAt < READINESS_CACHE_MS) { - return yield* cachedReadinessResponse(cache.status) - } - - return yield* Effect.matchEffect(healthCheckEffect, { - onFailure: (error) => - Effect.sync(() => { - readinessCache = { checkedAt: Date.now(), status: 500 } - }).pipe(Effect.flatMap(() => Effect.fail(error))), - onSuccess: () => - Effect.sync(() => { - readinessCache = { checkedAt: Date.now(), status: 200 } - }).pipe(Effect.as(readyResponse())) +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 HealthHandlers = 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 index 1d77e5d3..70f6bce7 100644 --- a/apps/vps/src/http/routes.ts +++ b/apps/vps/src/http/routes.ts @@ -9,9 +9,8 @@ import { } from 'effect/unstable/http' import { HttpApiBuilder, HttpApiScalar } from 'effect/unstable/httpapi' import type { AppType } from '@/app' -import { auth, prepareAuthRequest } from '@/lib/auth' import { corsConfig } from '@/lib/create-app' -import { HealthHandlers } from './health.handlers' +import { HealthHandlersLive, makeHealthHandlers, type HealthDatabase } from './health.handlers' const isAllowedOrigin = (origin: string) => corsConfig.origin(origin) === origin @@ -26,17 +25,6 @@ const CorsLive = HttpRouter.middleware( { global: true } ) -const BetterAuthRoutes = HttpRouter.use((router) => - router.add('*', '/auth/*', (request) => - HttpServerRequest.toWeb(request).pipe( - Effect.flatMap((webRequest) => - Effect.promise(() => auth.handler(prepareAuthRequest(webRequest))) - ), - Effect.map(HttpServerResponse.fromWeb) - ) - ) -) - const honoFallbackRoutes = (honoApp: AppType) => HttpRouter.use((router) => router.add('*', '/*', (request) => @@ -49,15 +37,30 @@ const honoFallbackRoutes = (honoApp: AppType) => ) ) -export const createWebHandler = (honoApp: AppType) => { +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) + Layer.provide(healthHandlers) ) const routes = Layer.mergeAll( apiRoutes, HttpApiScalar.layer(Api, { path: '/effect-reference' }), - BetterAuthRoutes, honoFallbackRoutes(honoApp) ).pipe(Layer.provide(CorsLive), Layer.provide(HttpServer.layerServices)) diff --git a/apps/vps/src/lib/auth.ts b/apps/vps/src/lib/auth.ts index 1016a290..1c87ab5a 100644 --- a/apps/vps/src/lib/auth.ts +++ b/apps/vps/src/lib/auth.ts @@ -179,16 +179,4 @@ export const auth = betterAuth({ ] }) -export function prepareAuthRequest(request: Request) { - const hasOrigin = request.headers.has('origin') || request.headers.has('referer') - - if (request.method === 'POST' && !hasOrigin && request.headers.has('cookie')) { - const headers = new Headers(request.headers) - headers.delete('cookie') - return new Request(request, { headers }) - } - - return request -} - export type AuthSession = typeof auth.$Infer.Session diff --git a/apps/vps/src/routes/user/better-auth.routes.ts b/apps/vps/src/routes/user/better-auth.routes.ts index d379c354..86b6a148 100644 --- a/apps/vps/src/routes/user/better-auth.routes.ts +++ b/apps/vps/src/routes/user/better-auth.routes.ts @@ -1,8 +1,20 @@ import { Hono } from 'hono' -import { auth, prepareAuthRequest } from '@/lib/auth' +import { auth } from '@/lib/auth' const betterAuthApp = new Hono() +function prepareAuthRequest(request: Request) { + const hasOrigin = request.headers.has('origin') || request.headers.has('referer') + + if (request.method === 'POST' && !hasOrigin && request.headers.has('cookie')) { + const headers = new Headers(request.headers) + headers.delete('cookie') + return new Request(request, { headers }) + } + + return request +} + betterAuthApp.on(['POST', 'GET'], '*', async (c) => { return auth.handler(prepareAuthRequest(c.req.raw)) }) From a8d38be0c4e515a758e9b7a9ac5ba855edbe20d4 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:41:28 +0200 Subject: [PATCH 06/10] feat(vps): wire up OTel observability in runtime services --- apps/vps/src/runtime/services.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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, From e81fe9e659d2ef8a038b7e2528c6ab77f69b2a7a Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:41:32 +0200 Subject: [PATCH 07/10] chore(vps): demote sentry disabled log to debug --- apps/vps/src/services/sentry-client.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 } } From fd49fdb3f8f857c0ed6996f4305bf9d9ec92c92e Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:41:36 +0200 Subject: [PATCH 08/10] refactor(api-test): remove @gbfm/api-test package, consolidate into scripts --- apps/vps/package.json | 1 - packages/api-test/package.json | 22 --- packages/api-test/src/health.remote.test.ts | 47 ------ packages/api-test/src/health.test.ts | 53 ------- packages/api-test/src/health.ts | 155 -------------------- packages/api-test/tsconfig.json | 25 ---- scripts/api-test/health.ts | 122 ++++++++++++++- 7 files changed, 120 insertions(+), 305 deletions(-) delete mode 100644 packages/api-test/package.json delete mode 100644 packages/api-test/src/health.remote.test.ts delete mode 100644 packages/api-test/src/health.test.ts delete mode 100644 packages/api-test/src/health.ts delete mode 100644 packages/api-test/tsconfig.json diff --git a/apps/vps/package.json b/apps/vps/package.json index bc05cb17..8908a329 100644 --- a/apps/vps/package.json +++ b/apps/vps/package.json @@ -72,7 +72,6 @@ }, "devDependencies": { "@effect/language-service": "0.86.4", - "@gbfm/api-test": "workspace:*", "@testcontainers/postgresql": "^11.14.0", "@types/aws-lambda": "8.10.161", "@types/bun": "1.3.13", diff --git a/packages/api-test/package.json b/packages/api-test/package.json deleted file mode 100644 index a2dad44e..00000000 --- a/packages/api-test/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@gbfm/api-test", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - "./health": "./src/health.ts" - }, - "scripts": { - "typecheck": "tsgo --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@gbfm/api": "workspace:*", - "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-test/src/health.remote.test.ts b/packages/api-test/src/health.remote.test.ts deleted file mode 100644 index cc95705f..00000000 --- a/packages/api-test/src/health.remote.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - API_URL, - makeHealthAssertions, - runHealthAssertion, - type HealthAssertionPath -} from './health' - -const configuredBaseUrl = process.env.GBFM_API_URL -const testBaseUrl = configuredBaseUrl ?? API_URL -const describeRemote = configuredBaseUrl ? describe : describe.skip - -const assertionFor = (path: HealthAssertionPath) => { - const assertion = makeHealthAssertions(testBaseUrl).find((item) => item.path === path) - - if (!assertion) { - throw new Error(`Missing health assertion for ${path}`) - } - - return assertion -} - -describeRemote('VPS health API curl assertions', () => { - it('curl -fsS "$GBFM_API_URL/health/live" | jq -e \'.ok == true\'', async () => { - const assertion = assertionFor('/health/live') - const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) - - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) - }) - - it('curl -fsS "$GBFM_API_URL/health/ready" | jq -e \'.dbConnected == true\'', async () => { - const assertion = assertionFor('/health/ready') - const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) - - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) - }) - - it('curl -fsS "$GBFM_API_URL/health" | jq -e \'.dbConnected == true\'', async () => { - const assertion = assertionFor('/health') - const result = await runHealthAssertion(assertion, { baseUrl: testBaseUrl }) - - expect(result.status).toBe(assertion.expectedStatus) - expect(result.body).toEqual(assertion.expectedBody) - }) -}) diff --git a/packages/api-test/src/health.test.ts b/packages/api-test/src/health.test.ts deleted file mode 100644 index 910f00c8..00000000 --- a/packages/api-test/src/health.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - makeHealthAssertions, - runHealthAssertions, - type ApiFetch, - type HealthAssertionBody -} from './health' - -const baseUrl = 'http://127.0.0.1:3003' - -describe('health assertions', () => { - it('exposes curl commands for each health endpoint', () => { - const assertions = makeHealthAssertions(baseUrl) - - expect(assertions.map((assertion) => assertion.curl)).toEqual([ - 'curl -fsS "http://127.0.0.1:3003/health/live" | jq -e \'.ok == true\'', - 'curl -fsS "http://127.0.0.1:3003/health/ready" | jq -e \'.dbConnected == true\'', - 'curl -fsS "http://127.0.0.1:3003/health" | jq -e \'.dbConnected == true\'' - ]) - }) - - it('runs the assertions through an injected fetch seam', async () => { - const paths: string[] = [] - const fetchHealth: ApiFetch = (request) => { - const path = new URL(request.url).pathname - paths.push(path) - - return Response.json(bodyForPath(path)) - } - - const results = await runHealthAssertions({ baseUrl, fetch: fetchHealth }) - - expect(paths).toEqual(['/health/live', '/health/ready', '/health']) - expect(results.map((result) => result.status)).toEqual([200, 200, 200]) - expect(results.map((result) => result.body)).toEqual([ - { ok: true }, - { dbConnected: true }, - { dbConnected: true } - ]) - }) -}) - -function bodyForPath(path: string): HealthAssertionBody { - switch (path) { - case '/health/live': - return { ok: true } - case '/health/ready': - case '/health': - return { dbConnected: true } - default: - throw new Error(`Unexpected health path: ${path}`) - } -} diff --git a/packages/api-test/src/health.ts b/packages/api-test/src/health.ts deleted file mode 100644 index 5ae91942..00000000 --- a/packages/api-test/src/health.ts +++ /dev/null @@ -1,155 +0,0 @@ -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' - -export const API_URL = process.env.GBFM_API_URL ?? 'http://127.0.0.1:3003' - -export type ApiFetch = (request: Request) => Promise | Response - -export type HealthAssertionPath = '/health/live' | '/health/ready' | '/health' - -export type HealthAssertionBody = HealthLiveResponse | HealthReadyResponse - -export interface HealthAssertion { - readonly name: string - readonly method: 'GET' - readonly path: HealthAssertionPath - readonly expectedStatus: 200 - readonly expectedBody: HealthAssertionBody - readonly curl: string -} - -export interface HealthAssertionOptions { - readonly baseUrl?: string - readonly fetch?: ApiFetch -} - -export interface HealthAssertionResult { - readonly assertion: HealthAssertion - readonly status: number - readonly headers: Headers - readonly bodyText: string - readonly body: HealthAssertionBody -} - -const healthLiveBody: HealthLiveResponse = { ok: true } -const healthReadyBody: HealthReadyResponse = { dbConnected: true } - -export function makeHealthAssertions(baseUrl = API_URL): readonly HealthAssertion[] { - return [ - { - 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'` - } - ] -} - -export async function runHealthAssertion( - assertion: HealthAssertion, - options: HealthAssertionOptions = {} -): Promise { - const baseUrl = options.baseUrl ?? API_URL - const send = options.fetch ?? fetchRequest - const response = await send( - new Request(new URL(assertion.path, baseUrl).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 { - assertion, - status: response.status, - headers: response.headers, - bodyText, - body - } -} - -export async function runHealthAssertions( - options: HealthAssertionOptions = {} -): Promise { - const baseUrl = options.baseUrl ?? API_URL - const results: HealthAssertionResult[] = [] - - for (const assertion of makeHealthAssertions(baseUrl)) { - results.push(await runHealthAssertion(assertion, options)) - } - - return results -} - -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 fetchRequest(request: Request) { - return globalThis.fetch(request) -} - -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('`', '\\`')}"` -} diff --git a/packages/api-test/tsconfig.json b/packages/api-test/tsconfig.json deleted file mode 100644 index 4f55ea1a..00000000 --- a/packages/api-test/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/scripts/api-test/health.ts b/scripts/api-test/health.ts index 60381853..f5581e51 100644 --- a/scripts/api-test/health.ts +++ b/scripts/api-test/health.ts @@ -1,8 +1,64 @@ -import { makeHealthAssertions, runHealthAssertion } from '@gbfm/api-test/health' +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 { 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)) if (help) { @@ -27,7 +83,7 @@ for (const assertion of makeHealthAssertions(API_URL)) { console.log('') try { - const result = await runHealthAssertion(assertion, { baseUrl: API_URL }) + const result = await runHealthAssertion(assertion) printResponse( { status: result.status, @@ -48,3 +104,65 @@ for (const assertion of makeHealthAssertions(API_URL)) { } 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('`', '\\`')}"` +} From 9be91b32dd460c8923e744e6ee71b24fd4d65842 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:41:40 +0200 Subject: [PATCH 09/10] chore: sync bun.lock after removing @gbfm/api-test dependency --- bun.lock | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/bun.lock b/bun.lock index 330f4c54..3ea91dfa 100644 --- a/bun.lock +++ b/bun.lock @@ -114,7 +114,6 @@ }, "devDependencies": { "@effect/language-service": "0.86.4", - "@gbfm/api-test": "workspace:*", "@testcontainers/postgresql": "^11.14.0", "@types/aws-lambda": "8.10.161", "@types/bun": "1.3.13", @@ -199,19 +198,6 @@ "vitest": "4.1.8", }, }, - "packages/api-test": { - "name": "@gbfm/api-test", - "version": "0.0.1", - "dependencies": { - "@gbfm/api": "workspace:*", - "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", @@ -765,8 +751,6 @@ "@gbfm/api": ["@gbfm/api@workspace:packages/api"], - "@gbfm/api-test": ["@gbfm/api-test@workspace:packages/api-test"], - "@gbfm/core": ["@gbfm/core@workspace:packages/core"], "@gbfm/email": ["@gbfm/email@workspace:packages/email"], From 21eae5e606a74b01a279b20b36eec562466b508a Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Fri, 10 Jul 2026 08:42:32 +0200 Subject: [PATCH 10/10] sst d.ts --- packages/api/sst-env.d.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/api/sst-env.d.ts 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