diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md new file mode 100644 index 00000000000..b4e020f37e8 --- /dev/null +++ b/.changeset/isolate-http-serve-routers.md @@ -0,0 +1,10 @@ +--- +"effect": patch +--- + +Build each `HttpRouter` entrypoint with a fresh router in a forked layer memo map. Routes on separate servers and handlers no longer leak into each other. + +Breaking changes: + +- Routes registered on a router provided by the app itself (for example, via `Layer.provide(HttpRouter.layer)`) are not served; the entrypoint serves its own router. For RPC, put the protocol and its handlers in the app passed to `serve`, for example `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. +- Layers first built inside an entrypoint are private to it. Providing the same stateful service to both the app and a sibling layer can build it twice depending on build order. Provide services that must be shared outside the entrypoint; services already built in the parent memo map are reused. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 93fd35091a2..e95adbd04e2 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -89,6 +89,7 @@ export interface HttpRouter { | Request.From<"GlobalError", Exclude> > + // @effect-diagnostics-next-line lazyEffect:off readonly asHttpEffect: () => Effect.Effect< HttpServerResponse.HttpServerResponse, unknown, @@ -598,6 +599,11 @@ export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) * `Scope`; route request markers are converted into the ordinary requirements of * the returned handler. * + * **Gotchas** + * + * The layer is built with a forked `MemoMap`, so calling `toHttpEffect` twice + * will build the same layer twice. + * * @stability unstable * @category converting * @since 4.0.0 @@ -613,11 +619,13 @@ export const toHttpEffect = ( Request.Without, Exclude, HttpRouter> | Scope.Scope > => - Effect.gen(function*() { - const context = yield* Layer.build(Layer.provideMerge(appLayer, layer)) - const router = Context.get(context, HttpRouter) - // @effect-diagnostics effect/returnEffectInGen:off - return router.asHttpEffect() + Effect.contextWith((context) => { + const scope = Context.get(context, Scope.Scope) + const memoMap = Layer.CurrentMemoMap.forkOrCreate(context) + return Effect.map(Layer.buildWithMemoMap(Layer.provideMerge(appLayer, layer), memoMap, scope), (context) => { + const router = Context.get(context, HttpRouter) + return router.asHttpEffect() + }) }) as any const RouteTypeId = "~effect/http/HttpRouter/Route" @@ -1265,6 +1273,16 @@ export const provideRequest = /** * Runs the provided application layer as an HTTP server. * + * **Details** + * + * Layers first built inside the app are private to this server. Provide + * services that must be shared with sibling layers outside `serve`. + * + * **Gotchas** + * + * The layer is built with a forked `MemoMap`, so calling `serve` twice will + * build the same layer twice. + * * @stability unstable * @category layers * @since 4.0.0 @@ -1305,17 +1323,25 @@ export const serve = | Request.On if (options?.disableLogger !== true) { middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger } - const RouterLayer = options?.routerConfig - ? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig)) - : layer - return Effect.gen(function*() { - const router = yield* HttpRouter - const handler = router.asHttpEffect() - return middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler) + const layerWithRouter = Layer.provideMerge( + appLayer, + options?.routerConfig + ? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig)) + : layer + ) + return Effect.contextWith((context) => { + const memoMap = Layer.CurrentMemoMap.forkOrCreate(context) + return Layer.buildWithMemoMap(layerWithRouter, memoMap, Context.get(context, Scope.Scope)) }).pipe( + Effect.map((context) => { + const router = Context.get(context, HttpRouter) + const handler = router.asHttpEffect() + return Layer.merge( + middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler), + Layer.succeedContext(context) + ) + }), Layer.unwrap, - Layer.provideMerge(appLayer), - Layer.provide(RouterLayer), options?.disableListenLog ? identity : HttpServer.withLogAddress ) as any } @@ -1393,9 +1419,12 @@ export const toWebHandler = < const RouterLayer = options?.routerConfig ? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig)) : layer - return HttpEffect.toWebHandlerLayerWith(Layer.provideMerge(appLayer, RouterLayer) as Layer.Layer, { - toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()), - middleware, - memoMap: options?.memoMap - }) + return HttpEffect.toWebHandlerLayerWith( + Layer.provideMerge(appLayer, RouterLayer) as Layer.Layer, + { + toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()), + middleware, + memoMap: options?.memoMap + } + ) } diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index dcacd1c8dd8..f2953137fe3 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -47,6 +47,45 @@ const IdParams = Schema.Struct({ const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { + it.effect("keeps routes isolated between independent servers", () => + Effect.gen(function*() { + const publicServer = Http.createServer() + const internalServer = Http.createServer() + + const Health = Layer.effectDiscard( + Effect.flatMap(HttpRouter.HttpRouter, (router) => + router.add("GET", "/health", HttpServerResponse.text("healthy"))) + ) + const publicApp = Layer.mergeAll( + HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), + Health + ) + const internalApp = Layer.mergeAll( + HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), + Health + ) + + yield* Layer.mergeAll( + HttpRouter.serve(publicApp, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provide(NodeHttpServer.layer(() => + publicServer, { port: 0 })) + ), + HttpRouter.serve(internalApp, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provide(NodeHttpServer.layer(() => internalServer, { port: 0 })) + ) + ).pipe(Layer.build) + + const status = (port: number, path: string) => + Effect.promise(() => fetch("http://localhost:" + port + path).then((response) => response.status)) + + assert.strictEqual(yield* status(tcpPort(publicServer), "/public"), 200) + assert.strictEqual(yield* status(tcpPort(internalServer), "/internal"), 200) + assert.strictEqual(yield* status(tcpPort(publicServer), "/health"), 200) + assert.strictEqual(yield* status(tcpPort(internalServer), "/health"), 200) + assert.strictEqual(yield* status(tcpPort(publicServer), "/internal"), 404) + assert.strictEqual(yield* status(tcpPort(internalServer), "/public"), 404) + })) + it.effect("schema", () => Effect.gen(function*() { yield* HttpRouter.add( diff --git a/packages/platform/node/test/RpcServer.test.ts b/packages/platform/node/test/RpcServer.test.ts index 96ddd14064d..7cdbf00e882 100644 --- a/packages/platform/node/test/RpcServer.test.ts +++ b/packages/platform/node/test/RpcServer.test.ts @@ -11,12 +11,10 @@ import { RpcLayer, User } from "./fixtures/rpc-schemas.ts" describe("RpcServer", () => { // http ndjson - const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe( - Layer.provide(HttpRouter.layer) - ) - const HttpNdjsonServer = RpcLayer.pipe( - Layer.provideMerge(HttpProtocol), - Layer.provide(HttpRouter.serve(HttpProtocol, { disableListenLog: true, disableLogger: true })) + const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }) + const HttpNdjsonServer = HttpRouter.serve( + RpcLayer.pipe(Layer.provideMerge(HttpProtocol)), + { disableListenLog: true, disableLogger: true } ) const HttpNdjsonClient = UsersClient.layer.pipe( Layer.provide( @@ -53,12 +51,10 @@ describe("RpcServer", () => { ) // websocket - const WsProtocol = RpcServer.layerProtocolWebsocket({ path: "/rpc" }).pipe( - Layer.provide(HttpRouter.layer) - ) - const HttpWsServer = RpcLayer.pipe( - Layer.provideMerge(WsProtocol), - Layer.provide(HttpRouter.serve(WsProtocol, { disableListenLog: true, disableLogger: true })) + const WsProtocol = RpcServer.layerProtocolWebsocket({ path: "/rpc" }) + const HttpWsServer = HttpRouter.serve( + RpcLayer.pipe(Layer.provideMerge(WsProtocol)), + { disableListenLog: true, disableLogger: true } ) const HttpWsClient = UsersClient.layer.pipe( Layer.provide(RpcClient.layerProtocolSocket()),