From 3d3ccba29a46a7409a2b404b8f2ba3df3c0a1f11 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 23 Aug 2026 14:45:20 +0200 Subject: [PATCH] perf(effect): lazily initialize HTTP API routes --- .../.changeset/lazy-httpapi-initialization.md | 7 + .../src/unstable/httpapi/HttpApiBuilder.ts | 28 +- .../src/unstable/httpapi/HttpApiScalar.ts | 66 +++-- .../src/unstable/httpapi/HttpApiSwagger.ts | 14 +- .../unstable/httpapi/HttpApiBuilder.test.ts | 126 +++++++- .../httpapi/HttpApiDocumentation.test.ts | 270 +++++++++++++++++- patches/effect@4.0.0-rc.108.patch | 101 +++++++ 7 files changed, 553 insertions(+), 59 deletions(-) create mode 100644 .context/effect/.changeset/lazy-httpapi-initialization.md diff --git a/.context/effect/.changeset/lazy-httpapi-initialization.md b/.context/effect/.changeset/lazy-httpapi-initialization.md new file mode 100644 index 000000000..eb9e43b37 --- /dev/null +++ b/.context/effect/.changeset/lazy-httpapi-initialization.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Defer HTTP API endpoint compilation and built-in OpenAPI response generation until their routes are requested. + +Endpoint codec and middleware initialization defects now surface on the first request to that route and are retried on later requests. Scalar, Swagger, and `openapiPath` responses memoize only successful generation so a documentation defect does not poison the route for the lifetime of the runtime. diff --git a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 062dc839e..3676fe266 100644 --- a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -57,6 +57,12 @@ import * as OpenApi from "./OpenApi.ts" /** * Registers an `HttpApi` with a `HttpRouter`. * + * **Details** + * + * When `openapiPath` is configured, the OpenAPI document and response are + * generated on the first request to that path. If generation defects, the + * response is left uninitialized so a later request can retry it. + * * @category layers * @since 4.0.0 */ @@ -100,8 +106,17 @@ export const layer = } yield* (router.addAll(routes) as Effect.Effect) if (options?.openapiPath) { - const spec = OpenApi.fromApi(api) - yield* router.add("GET", options.openapiPath, Effect.succeed(Response.jsonUnsafe(spec))) + let response: HttpServerResponse | undefined + yield* router.add( + "GET", + options.openapiPath, + Effect.sync(() => { + if (response !== undefined) return response + const spec = OpenApi.fromApi(api) + response = Response.jsonUnsafe(spec) + return response + }) + ) } })) @@ -114,6 +129,10 @@ export const layer = * be used to add handlers to the group. Implement endpoints with * `handlers.handle`. * + * Endpoint schema codecs and middleware wrappers are initialized when their + * route is first requested. A synchronous initialization defect fails that + * request and leaves the route uninitialized so a later request can retry it. + * * @category handlers * @since 4.0.0 */ @@ -839,10 +858,13 @@ export function handlerToRoute( context: Context.Context ): HttpRouter.Route { const endpoint = handler.endpoint + // Schema encoders, decoders, and middleware are route-local, so defer them + // until this route first runs instead of charging every endpoint up front. + let httpEffect: Effect.Effect | undefined return HttpRouter.route( endpoint.method, endpoint.path as HttpRouter.PathInput, - handlerToHttpEffect(group, endpoint, context, handler.handler, handler.isRaw), + Effect.suspend(() => httpEffect ??= handlerToHttpEffect(group, endpoint, context, handler.handler, handler.isRaw)), { uninterruptible: handler.uninterruptible } ) } diff --git a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts index 22900eede..6a2f8ed44 100644 --- a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts +++ b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts @@ -144,36 +144,39 @@ const makeHandler = ( readonly source: ScalarSource readonly scalar: ScalarConfig | undefined }) => { - const spec = OpenApi.fromApi(options.api) - const { customFetch, ...scalar } = options.scalar ?? {} - const scalarConfig = { - _integration: "html", - ...scalar - } - const scalarScript = options.source._tag === "Cdn" - ? `` - : `` - const response = HttpServerResponse.html(` + let response: HttpServerResponse.HttpServerResponse | undefined + return Effect.sync(() => { + if (response !== undefined) return response + const spec = OpenApi.fromApi(options.api) + const { customFetch, ...scalar } = options.scalar ?? {} + const scalarConfig = { + _integration: "html", + ...scalar + } + const scalarScript = options.source._tag === "Cdn" + ? `` + : `` + response = HttpServerResponse.html(` ${Html.escape(spec.info.title)} ${ - !spec.info.description - ? "" - : `` - } + !spec.info.description + ? "" + : `` + } ${ - !spec.info.description - ? "" - : `` - } + !spec.info.description + ? "" + : `` + } @@ -185,14 +188,15 @@ const makeHandler = ( window.Scalar.createApiReference(document.getElementById('api-reference-container'), { ...${Html.escapeJson(scalarConfig)}, content: ${Html.escapeJson(spec)}${ - customFetch === undefined ? "" : `, + customFetch === undefined ? "" : `, customFetch: ${customFetch}` - } + } }) `) - return Effect.succeed(response) + return response + }) } /** @@ -201,7 +205,9 @@ const makeHandler = ( * **Details** * * The route serves the OpenAPI specification generated from the API at the - * configured path, defaulting to `/docs`. + * configured path, defaulting to `/docs`. The document and response are + * generated on the first request and memoized after successful generation; + * defects are retried by later requests. * * @category layers * @since 4.0.0 @@ -232,7 +238,9 @@ export const layer = * * The route serves the OpenAPI specification generated from the API at the * configured path, defaulting to `/docs`; `version` selects the Scalar package - * version loaded from the CDN. + * version loaded from the CDN. The document and response are generated on the + * first request and memoized after successful generation; defects are retried + * by later requests. * * @category layers * @since 4.0.0 diff --git a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts index d6870c9ee..d26550fcc 100644 --- a/.context/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts +++ b/.context/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts @@ -21,8 +21,11 @@ import * as OpenApi from "./OpenApi.ts" const makeHandler = (options: { readonly api: HttpApi.HttpApi }) => { - const spec = OpenApi.fromApi(options.api) - const response = HttpServerResponse.html(` + let response: HttpServerResponse.HttpServerResponse | undefined + return Effect.sync(() => { + if (response !== undefined) return response + const spec = OpenApi.fromApi(options.api) + response = HttpServerResponse.html(` @@ -46,12 +49,15 @@ const makeHandler = ( `) - return Effect.succeed(response) + return response + }) } /** * Mounts Swagger UI for an `HttpApi` at the configured path, defaulting to - * `/docs`, using the OpenAPI specification generated from the API. + * `/docs`, using the OpenAPI specification generated from the API. The document + * and response are generated on the first request and memoized after successful + * generation; defects are retried by later requests. * * @category layers * @since 4.0.0 diff --git a/.context/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts b/.context/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index 5dedefe87..3bb2ac606 100644 --- a/.context/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/.context/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -11,7 +11,7 @@ import { SchemaTransformation, Stream } from "effect" -import { Etag, HttpPlatform } from "effect/unstable/http" +import { Etag, HttpPlatform, HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, @@ -59,15 +59,22 @@ it.layer(TestServices)("HttpApiBuilder query parameters", (it) => { })) }) -it.effect("reuses response schema transformations by source AST", () => { +it.effect("builds endpoint handlers lazily and memoizes them per route", () => { + class CountedMiddleware extends HttpApiMiddleware.Service()("CountedMiddleware") {} + + let middlewareApplications = 0 const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText()) const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/custom" })) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test") - .add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess })) - .add(HttpApiEndpoint.get("second", "/second", { success: SharedSuccess })) - .add(HttpApiEndpoint.get("distinct", "/distinct", { success: DistinctSuccess })) + .add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess }).middleware(CountedMiddleware)) + .add(HttpApiEndpoint.get("second", "/second", { success: SharedSuccess }).middleware(CountedMiddleware)) + .add(HttpApiEndpoint.get("distinct", "/distinct", { success: DistinctSuccess }).middleware(CountedMiddleware)) ) + const CountedMiddlewareLive = Layer.succeed(CountedMiddleware)((httpEffect) => { + middlewareApplications++ + return httpEffect + }) const GroupLive = HttpApiBuilder.group( Api, "test", @@ -76,22 +83,105 @@ it.effect("reuses response schema transformations by source AST", () => { .handle("first", () => Effect.succeed("first")) .handle("second", () => Effect.succeed("second")) .handle("distinct", () => Effect.succeed("distinct")) - ) + ).pipe(Layer.provide(CountedMiddlewareLive)) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + const Routes = Layer.merge( + HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)), + Health + ).pipe(Layer.provide(TestServices)) return Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(Schema, "decodeTo")), (decodeTo) => - Effect.gen(function*() { - yield* Effect.scoped(Layer.build(GroupLive)) - const responseSchemaCalls = decodeTo.mock.calls.filter( - ([schema]) => schema === SharedSuccess || schema === DistinctSuccess - ) - assert.strictEqual(responseSchemaCalls.length, 2) - }), + withHandler(Routes, (handler) => + Effect.gen(function*() { + const responseSchemaCalls = () => + decodeTo.mock.calls.filter( + ([schema]) => schema === SharedSuccess || schema === DistinctSuccess + ) + + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(responseSchemaCalls().length, 0) + assert.strictEqual(middlewareApplications, 0) + + const first = yield* Effect.promise(() => handler(new Request("http://test/first"))) + assert.strictEqual(first.status, 200) + assert.strictEqual(responseSchemaCalls().length, 1) + assert.strictEqual(middlewareApplications, 1) + + const firstAgain = yield* Effect.promise(() => handler(new Request("http://test/first"))) + assert.strictEqual(firstAgain.status, 200) + assert.strictEqual(responseSchemaCalls().length, 1) + assert.strictEqual(middlewareApplications, 1) + + const second = yield* Effect.promise(() => + Promise.all( + Array.from({ length: 20 }, () => handler(new Request("http://test/second"))) + ) + ) + assert.ok(second.every((response) => response.status === 200)) + assert.strictEqual(responseSchemaCalls().length, 1) + assert.strictEqual(middlewareApplications, 2) + + const distinct = yield* Effect.promise(() => handler(new Request("http://test/distinct"))) + assert.strictEqual(distinct.status, 200) + assert.strictEqual(responseSchemaCalls().length, 2) + assert.strictEqual(middlewareApplications, 3) + })), (decodeTo) => Effect.sync(() => decodeTo.mockRestore()) ) }) +it.effect("retries lazy endpoint initialization after a middleware construction defect", () => { + class FlakyMiddleware extends HttpApiMiddleware.Service()("FlakyMiddleware") {} + + let middlewareApplications = 0 + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.get("flaky", "/flaky", { + success: Schema.String.pipe(HttpApiSchema.asText()) + }).middleware(FlakyMiddleware) + ) + ) + const FlakyMiddlewareLive = Layer.succeed(FlakyMiddleware)((httpEffect) => { + middlewareApplications++ + if (middlewareApplications === 1) { + throw new Error("middleware construction defect") + } + return httpEffect + }) + const GroupLive = HttpApiBuilder.group( + Api, + "test", + (handlers) => handlers.handle("flaky", () => Effect.succeed("ok")) + ).pipe(Layer.provide(FlakyMiddlewareLive)) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + const Routes = Layer.merge( + HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)), + Health + ).pipe(Layer.provide(TestServices)) + + return withHandler(Routes, (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(middlewareApplications, 0) + + const first = yield* Effect.promise(() => handler(new Request("http://test/flaky"))) + assert.strictEqual(first.status, 500) + assert.strictEqual(middlewareApplications, 1) + + const second = yield* Effect.promise(() => handler(new Request("http://test/flaky"))) + assert.strictEqual(second.status, 200) + assert.strictEqual(middlewareApplications, 2) + + const third = yield* Effect.promise(() => handler(new Request("http://test/flaky"))) + assert.strictEqual(third.status, 200) + assert.strictEqual(middlewareApplications, 2) + })) +}) + it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { it.effect("round trips mixed-case media types with declared and received parameters", () => Effect.gen(function*() { @@ -1371,3 +1461,13 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { assert.deepStrictEqual(error, new HandlerFailure({ message: "handler failed" })) })) }) + +const withHandler = ( + layer: Layer.Layer, + use: (handler: (request: Request) => Promise) => Effect.Effect +) => + Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(layer, { disableLogger: true })), + ({ handler }) => use(handler), + ({ dispose }) => Effect.promise(dispose) + ) diff --git a/.context/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts b/.context/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts index 29c230f03..04a4a9d2d 100644 --- a/.context/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts +++ b/.context/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts @@ -1,9 +1,185 @@ -import { assert, describe, it } from "@effect/vitest" -import { Effect, type Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi" +import { assert, describe, it, vi } from "@effect/vitest" +import { Effect, FileSystem, Layer, Path } from "effect" +import { Etag, HttpPlatform, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi" + +const TestServices = Layer.mergeAll( + Path.layer, + Etag.layerWeak, + HttpPlatform.layer +).pipe(Layer.provideMerge(FileSystem.layerNoop({}))) + +describe("HttpApiBuilder", () => { + it.effect("defers and memoizes successful openapiPath responses", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("OpenApiPath").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(HttpServerResponse, "jsonUnsafe")), + (jsonUnsafe) => + withHandler( + Layer.merge(HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }), Health).pipe( + Layer.provide(TestServices) + ), + (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + assert.strictEqual(jsonUnsafe.mock.calls.length, 0) + + const firstRequests = yield* Effect.promise(() => + Promise.all( + Array.from({ length: 20 }, () => handler(new Request("http://test/openapi.json"))) + ) + ) + assert.ok(firstRequests.every((response) => response.status === 200)) + assert.strictEqual(transforms, 1) + assert.strictEqual(jsonUnsafe.mock.calls.length, 1) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 1) + assert.strictEqual(jsonUnsafe.mock.calls.length, 1) + }) + ), + (jsonUnsafe) => Effect.sync(() => jsonUnsafe.mockRestore()) + ) + })) + + it.effect("retries openapiPath generation after a defect", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("OpenApiPathRecovery").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + if (transforms === 1) throw new Error("OpenAPI generation defect") + return spec + } + ) + + yield* withHandler( + HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(Layer.provide(TestServices)), + (handler) => + Effect.gen(function*() { + const first = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(first.status, 500) + assert.strictEqual(transforms, 1) + + const second = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(second.status, 200) + assert.strictEqual(transforms, 2) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 2) + }) + ) + })) +}) describe("HttpApiScalar", () => { + it.effect("defers and memoizes successful OpenAPI responses", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("Docs").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(HttpServerResponse, "html")), + (html) => + withHandler(Layer.merge(HttpApiScalar.layerCdn(Api), Health), (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + assert.strictEqual(html.mock.calls.length, 0) + + const firstRequests = yield* Effect.promise(() => + Promise.all( + Array.from({ length: 20 }, () => handler(new Request("http://test/docs"))) + ) + ) + assert.ok(firstRequests.every((response) => response.status === 200)) + assert.strictEqual(transforms, 1) + assert.strictEqual(html.mock.calls.length, 1) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 1) + assert.strictEqual(html.mock.calls.length, 1) + })), + (html) => Effect.sync(() => html.mockRestore()) + ) + })) + + it.effect("retries OpenAPI generation after a defect", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("ScalarRecovery").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + if (transforms === 1) throw new Error("OpenAPI generation defect") + return spec + } + ) + + yield* withHandler(HttpApiScalar.layerCdn(Api), (handler) => + Effect.gen(function*() { + const first = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(first.status, 500) + assert.strictEqual(transforms, 1) + + const second = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(second.status, 200) + assert.strictEqual(transforms, 2) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 2) + })) + })) + + it.effect("defers inline Scalar generation until the route is requested", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("ScalarInline").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* withHandler(Layer.merge(HttpApiScalar.layer(Api), Health), (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + + const docs = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(docs.status, 200) + assert.strictEqual(transforms, 1) + })) + })) + it.effect("escapes OpenAPI metadata in its HTML contexts", () => Effect.gen(function*() { const title = `Docs "title" ` : ``; +- const response = HttpServerResponse.html(` ++ let response; ++ return Effect.sync(() => { ++ if (response !== undefined) return response; ++ const spec = OpenApi.fromApi(options.api); ++ const { ++ customFetch, ++ ...scalar ++ } = options.scalar ?? {}; ++ const scalarConfig = { ++ _integration: "html", ++ ...scalar ++ }; ++ const scalarScript = options.source._tag === "Cdn" ? `` : ``; ++ response = HttpServerResponse.html(` + + + +@@ -48,7 +51,8 @@ + + + `); +- return Effect.succeed(response); ++ return response; ++ }); + }; + /** + * Mounts a Scalar API reference page for an `HttpApi` using the bundled Scalar script. +diff --git a/dist/unstable/httpapi/HttpApiSwagger.js b/dist/unstable/httpapi/HttpApiSwagger.js +--- a/dist/unstable/httpapi/HttpApiSwagger.js ++++ b/dist/unstable/httpapi/HttpApiSwagger.js +@@ -15,8 +15,11 @@ + import * as internal from "./internal/httpApiSwagger.js"; + import * as OpenApi from "./OpenApi.js"; + const makeHandler = options => { +- const spec = OpenApi.fromApi(options.api); +- const response = HttpServerResponse.html(` ++ let response; ++ return Effect.sync(() => { ++ if (response !== undefined) return response; ++ const spec = OpenApi.fromApi(options.api); ++ response = HttpServerResponse.html(` + + + +@@ -40,7 +43,8 @@ + + + `); +- return Effect.succeed(response); ++ return response; ++ }); + }; + /** + * Mounts Swagger UI for an `HttpApi` at the configured path, defaulting to