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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/vps/src/http/routes.blackbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { HealthLiveResponse, HealthReadyResponse } from '@gbfm/api/health'
import { decodeResponseBody } from '@gbfm/api/testing'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AppType } from '@/app'
import { createWebHandler } from './routes'

// Step 2a (docs/migration-effect-http-api.md): the Effect toWebHandler + Hono
// fallback must behave identically to the plain Hono app it wraps. Reuses the
// same @gbfm/api schemas as the 1b blackbox suite so both point at one contract.
let app: AppType
let webHandler: ReturnType<typeof createWebHandler>

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

afterAll(async () => {
await webHandler.dispose()
})

describe('Effect toWebHandler fallback', () => {
it('GET /health/live matches the Hono app', async () => {
const res = await webHandler.handler(new Request('http://localhost/health/live'))

expect(res.status).toBe(200)
await expect(decodeResponseBody(HealthLiveResponse, res)).resolves.toEqual({ ok: true })
})

it('GET /health/ready matches the Hono app', async () => {
const res = await webHandler.handler(new Request('http://localhost/health/ready'))

expect(res.status).toBe(200)
await expect(decodeResponseBody(HealthReadyResponse, res)).resolves.toEqual({
dbConnected: true
})
})

it('GET /api/music/artists falls through to the Hono app', async () => {
const res = await webHandler.handler(new Request('http://localhost/api/music/artists'))

expect(res.status).toBe(200)
const body: unknown = await res.json()
expect(Array.isArray(body)).toBe(true)
})

it('unknown routes fall through to the Hono app and 404', async () => {
const res = await webHandler.handler(new Request('http://localhost/does-not-exist'))

expect(res.status).toBe(404)
})
})
16 changes: 16 additions & 0 deletions apps/vps/src/http/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Effect, Layer } from 'effect'
import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http'
import type { AppType } from '@/app'

// Routes every request to the existing Hono app unchanged. Removed one HttpApi group at a time as routes are ported (docs/migration-effect-http-api.md).
export const honoFallback = (honoApp: AppType) =>
HttpRouter.add('*', '/*', (request) =>
Effect.gen(function* () {
const webRequest = yield* HttpServerRequest.toWeb(request)
const webResponse = yield* Effect.promise(() => Promise.resolve(honoApp.fetch(webRequest)))
return HttpServerResponse.fromWeb(webResponse)
})
)

export const createWebHandler = (honoApp: AppType) =>
HttpRouter.toWebHandler(Layer.mergeAll(honoFallback(honoApp), HttpServer.layerServices))
5 changes: 5 additions & 0 deletions apps/vps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export const localVPSPort = 3003

const { default: app } = await import('./app')

// Step 2a (docs/migration-effect-http-api.md): proves the toWebHandler + fallback
// layer builds and serves identically to the Hono app. Not wired to Bun.serve yet.
const { createWebHandler } = await import('./http/routes')
export const effectWebHandler = createWebHandler(app)

export default {
port: localVPSPort,
fetch: app.fetch,
Expand Down
32 changes: 19 additions & 13 deletions docs/migration-effect-http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,15 +501,19 @@ The entry point must carry over **everything** in the app.ts inventory, not just
| Graceful shutdown | SIGTERM/SIGINT → `toWebHandler`'s `dispose()` then `disposeRuntime()` |
| 1GB request bodies | unchanged `maxRequestBodySize` on the Bun.serve export |

better-auth mounts as a wildcard route. Its handler is async and returns a web `Response`, so it needs `Effect.promise` and `HttpServerResponse.fromWeb` -- `Effect.succeed(auth.handler(...))` would produce an unawaited `Promise` typed as the response:
better-auth mounts as a wildcard route. Its handler is async and returns a web `Response`, so it needs `Effect.promise` and `HttpServerResponse.fromWeb` -- `Effect.succeed(auth.handler(...))` would produce an unawaited `Promise` typed as the response.

**Verified against the installed types (corrects an earlier version of this doc)**: `HttpServerRequest.toWeb` is not a plain sync conversion -- it returns `Effect.Effect<Request, RequestError>` and must be `yield*`ed inside an `Effect.gen`, not called bare inside `Effect.promise`:

```ts
const BetterAuthRoutes = HttpRouter.use((router) =>
router.add("*", "/auth/*", (request) =>
Effect.promise(() =>
auth.handler(prepareAuthRequest(HttpServerRequest.toWeb(request))),
).pipe(Effect.map(HttpServerResponse.fromWeb)),
),
const BetterAuthRoutes = HttpRouter.add("*", "/auth/*", (request) =>
Effect.gen(function* () {
const webRequest = yield* HttpServerRequest.toWeb(request)
const webResponse = yield* Effect.promise(() =>
Promise.resolve(auth.handler(prepareAuthRequest(webRequest))),
)
return HttpServerResponse.fromWeb(webResponse)
}),
)
```

Expand Down Expand Up @@ -565,15 +569,17 @@ One server, one port, one deploy at a time. The whole existing Hono app mounts a
```ts
import honoApp from "@/app"

const HonoFallback = HttpRouter.use((router) =>
router.add("*", "/*", (request) =>
Effect.promise(() => honoApp.fetch(HttpServerRequest.toWeb(request))).pipe(
Effect.map(HttpServerResponse.fromWeb),
),
),
const HonoFallback = HttpRouter.add("*", "/*", (request) =>
Effect.gen(function* () {
const webRequest = yield* HttpServerRequest.toWeb(request)
const webResponse = yield* Effect.promise(() => Promise.resolve(honoApp.fetch(webRequest)))
return HttpServerResponse.fromWeb(webResponse)
}),
)
```

(Implemented and verified in `apps/vps/src/http/routes.ts`, step 2a. `honoApp.fetch` can return `Response | Promise<Response>` depending on the route; wrap in `Promise.resolve` so `Effect.promise`'s `PromiseLike` constraint is satisfied either way.)

The router (find-my-way) gives static and parametric routes precedence over the wildcard, so each `HttpApi` group added takes over its paths automatically. Per group: port endpoints + handlers, delete the corresponding Hono router from `app.ts`, deploy. When `app.ts` has no routers left, delete `HonoFallback` and the Hono dependency.

**Middleware double-application caveat**: while the fallback exists, requests hitting Hono routes pass through both the Effect global middleware and Hono's own cors/rate-limit/logger. For CORS and logging that is harmless duplication; for rate limiting it would double-count. During the transition, keep the rate limiter on the Hono side only, and add `RateLimiterLive` in the same deploy that removes the fallback. Same for Sentry: Hono's `onError` keeps covering fallback routes until the end.
Expand Down