From 9899df5459f9425345dd122086296e32241d9fd8 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Sat, 11 Jul 2026 00:57:59 +0200 Subject: [PATCH 1/2] feat(vps): add unused Effect toWebHandler + Hono fallback (Step 2a) Step 2a of the Hono -> Effect HttpApi migration (docs/migration-effect-http-api.md). Nothing in prod changes: the new handler is exported but not wired to Bun.serve. - apps/vps/src/http/routes.ts: honoFallback wildcards every request to the existing Hono app's fetch unchanged. createWebHandler wraps it with HttpRouter.toWebHandler + HttpServer.layerServices. - apps/vps/src/index.ts: builds and exports effectWebHandler alongside the existing Bun.serve export. The entry point still serves via the old Hono path. Note: the migration doc's Phase 5 sketch calls HttpServerRequest.toWeb(request) as a plain sync conversion, but the real signature returns Effect.Effect -- it must be yielded inside the Effect.gen, not called bare. Caught by typechecking against the installed effect@4.0.0-beta.93 types rather than the doc's snippet. routes.blackbox.test.ts: reuses the @gbfm/api schemas from step 1b to assert the new handler behaves identically to the Hono app for health, a real API route (music/artists), and an unknown route (404) -- 4/4 passing against a live Postgres testcontainer. --- apps/vps/src/http/routes.blackbox.test.ts | 53 +++++++++++++++++++++++ apps/vps/src/http/routes.ts | 16 +++++++ apps/vps/src/index.ts | 5 +++ 3 files changed, 74 insertions(+) create mode 100644 apps/vps/src/http/routes.blackbox.test.ts create mode 100644 apps/vps/src/http/routes.ts diff --git a/apps/vps/src/http/routes.blackbox.test.ts b/apps/vps/src/http/routes.blackbox.test.ts new file mode 100644 index 00000000..8207f038 --- /dev/null +++ b/apps/vps/src/http/routes.blackbox.test.ts @@ -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 + +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) + }) +}) diff --git a/apps/vps/src/http/routes.ts b/apps/vps/src/http/routes.ts new file mode 100644 index 00000000..59e747bf --- /dev/null +++ b/apps/vps/src/http/routes.ts @@ -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)) diff --git a/apps/vps/src/index.ts b/apps/vps/src/index.ts index 5cc347f2..9755dad9 100644 --- a/apps/vps/src/index.ts +++ b/apps/vps/src/index.ts @@ -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, From 8d03010b6f535d6d9cd6739764640eb0d1a701f6 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Sat, 11 Jul 2026 00:58:31 +0200 Subject: [PATCH 2/2] docs: correct HttpServerRequest.toWeb usage in migration doc Discovered while implementing step 2a: HttpServerRequest.toWeb returns Effect.Effect, not a plain Request. Update the better-auth and Hono-fallback snippets to yield* it inside Effect.gen, matching apps/vps/src/http/routes.ts. --- docs/migration-effect-http-api.md | 32 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/migration-effect-http-api.md b/docs/migration-effect-http-api.md index 345b066e..44a22f64 100644 --- a/docs/migration-effect-http-api.md +++ b/docs/migration-effect-http-api.md @@ -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` 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) + }), ) ``` @@ -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` 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.