From f85a075f4fb3a90b51168e5ad6fae7281e59f6bc Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 04:44:08 +0000 Subject: [PATCH 01/35] test: reproduce shared routes across independent HTTP servers --- .../platform/node/test/NodeHttpServer.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index dcacd1c8dd8..35f6e902b90 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -47,6 +47,39 @@ 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 publicApp = HttpRouter.add("GET", "/public", HttpServerResponse.text("public")) + const internalApp = HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")) + + 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 publicAddress = publicServer.address() + const internalAddress = internalServer.address() + if ( + !publicAddress || typeof publicAddress === "string" || + !internalAddress || typeof internalAddress === "string" + ) { + throw new Error("Expected both servers to listen on TCP ports") + } + const status = (port: number, path: string) => + Effect.promise(() => fetch("http://localhost:" + port + path).then((response) => response.status)) + + assert.strictEqual(yield* status(publicAddress.port, "/public"), 200) + assert.strictEqual(yield* status(internalAddress.port, "/internal"), 200) + assert.strictEqual(yield* status(publicAddress.port, "/internal"), 404) + assert.strictEqual(yield* status(internalAddress.port, "/public"), 404) + })) + it.effect("schema", () => Effect.gen(function*() { yield* HttpRouter.add( From 50c2f80d28ffa842859aa0f96941900f05beec7b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 04:45:53 +0000 Subject: [PATCH 02/35] fix(http): isolate routers for independent servers --- packages/effect/src/http/HttpRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 93fd35091a2..b259dc8f24b 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -1315,7 +1315,7 @@ export const serve = | Request.On }).pipe( Layer.unwrap, Layer.provideMerge(appLayer), - Layer.provide(RouterLayer), + Layer.provide(Layer.fresh(RouterLayer)), options?.disableListenLog ? identity : HttpServer.withLogAddress ) as any } From 0d749d78402d9ad13a533b3e5e213c39e2afe3e8 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 04:55:40 +0000 Subject: [PATCH 03/35] test(rpc): register HTTP protocols within served router --- packages/platform/node/test/RpcServer.test.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) 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()), From 8870e929bea7539f67c13f123252a35344a5f7c2 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 04:57:05 +0000 Subject: [PATCH 04/35] docs(changeset): note isolated HttpRouter.serve migration --- .changeset/isolate-http-serve-routers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/isolate-http-serve-routers.md diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md new file mode 100644 index 00000000000..cb4651b71ff --- /dev/null +++ b/.changeset/isolate-http-serve-routers.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + ++`HttpRouter.serve` now uses a separate router for each server, so routes on one listener are no longer available on another listener in the same layer graph. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer served; move that route-producing layer into the app passed to `serve`. For example, replace a separately provided `RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe(Layer.provide(HttpRouter.layer))` with `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. From eb2c825d3f09f7f03707ff94575eacb4a161a8c6 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 04:57:18 +0000 Subject: [PATCH 05/35] docs(changeset): remove stray marker --- .changeset/isolate-http-serve-routers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index cb4651b71ff..c7b85212739 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,4 +2,4 @@ "effect": patch --- -+`HttpRouter.serve` now uses a separate router for each server, so routes on one listener are no longer available on another listener in the same layer graph. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer served; move that route-producing layer into the app passed to `serve`. For example, replace a separately provided `RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe(Layer.provide(HttpRouter.layer))` with `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. +`HttpRouter.serve` now uses a separate router for each server, so routes on one listener are no longer available on another listener in the same layer graph. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer served; move that route-producing layer into the app passed to `serve`. For example, replace a separately provided `RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe(Layer.provide(HttpRouter.layer))` with `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. From 6d24dccb24e5d8ad65332352a98b60f4df87a228 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:01:01 +0000 Subject: [PATCH 06/35] docs(changeset): qualify serve isolation and clarify migration --- .changeset/isolate-http-serve-routers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index c7b85212739..b73c5e13b9e 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,4 +2,4 @@ "effect": patch --- -`HttpRouter.serve` now uses a separate router for each server, so routes on one listener are no longer available on another listener in the same layer graph. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer served; move that route-producing layer into the app passed to `serve`. For example, replace a separately provided `RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe(Layer.provide(HttpRouter.layer))` with `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. +`HttpRouter.serve` now provides a fresh router for each server, preventing routes from leaking between listeners in the same layer graph when the apps do not supply their own router. If an app supplies a shared `HttpRouter` itself, `serve` uses that router and the listeners can still share routes. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer implicitly served; move the route-producing layer into the app passed to `serve`. For example, instead of providing `HttpRouter.layer` to `RpcServer.layerProtocolHttp({ path: "/rpc" })` separately, use `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`, where `YourRpcHandlers` is your application layer containing the RPC handlers. From 6b80850983662de74793bc03729b6ab1532df1ed Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:09:19 +0000 Subject: [PATCH 07/35] Tighten router isolation release note and remove redundant comments --- .changeset/isolate-http-serve-routers.md | 4 +++- packages/platform/node/test/RpcServer.test.ts | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index b73c5e13b9e..8ff876da025 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,4 +2,6 @@ "effect": patch --- -`HttpRouter.serve` now provides a fresh router for each server, preventing routes from leaking between listeners in the same layer graph when the apps do not supply their own router. If an app supplies a shared `HttpRouter` itself, `serve` uses that router and the listeners can still share routes. Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer implicitly served; move the route-producing layer into the app passed to `serve`. For example, instead of providing `HttpRouter.layer` to `RpcServer.layerProtocolHttp({ path: "/rpc" })` separately, use `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`, where `YourRpcHandlers` is your application layer containing the RPC handlers. +`HttpRouter.serve` now uses a fresh router per server, so listeners no longer share routes by default. Apps that supply the same router can still share routes. + +Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer implicitly served and may return 404. Put the route-producing layer inside the app passed to `serve`, for example `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`, where `YourRpcHandlers` is your RPC application layer. diff --git a/packages/platform/node/test/RpcServer.test.ts b/packages/platform/node/test/RpcServer.test.ts index 7cdbf00e882..7a7589ff154 100644 --- a/packages/platform/node/test/RpcServer.test.ts +++ b/packages/platform/node/test/RpcServer.test.ts @@ -10,7 +10,6 @@ import { e2eSuite, UsersClient } from "./fixtures/rpc-e2e.ts" import { RpcLayer, User } from "./fixtures/rpc-schemas.ts" describe("RpcServer", () => { - // http ndjson const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }) const HttpNdjsonServer = HttpRouter.serve( RpcLayer.pipe(Layer.provideMerge(HttpProtocol)), @@ -50,7 +49,6 @@ describe("RpcServer", () => { ) ) - // websocket const WsProtocol = RpcServer.layerProtocolWebsocket({ path: "/rpc" }) const HttpWsServer = HttpRouter.serve( RpcLayer.pipe(Layer.provideMerge(WsProtocol)), From 0633186241f819a1422c1499ae5b1915dc7b87c7 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:31:18 +0000 Subject: [PATCH 08/35] test(http): specify router ownership across entrypoints --- .../test/ai/McpServer/McpServer.test.ts | 4 +- packages/effect/test/http/HttpRouter.test.ts | 75 ++++++++++++++++++- .../effect/typetest/http/HttpRouter.tst.ts | 20 +++++ .../platform/node/test/NodeHttpServer.test.ts | 16 +++- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 2087a7ef963..6c7d9c933e6 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -2046,7 +2046,6 @@ describe("McpServer", () => { }).pipe(Effect.forkScoped) yield* router.add("POST", "/mcp", () => httpEffect) })).pipe( - Layer.provideMerge(HttpRouter.layer), Layer.provide(RpcSerialization.layerJsonRpc()) ) const harness = yield* makeHttpHarness(serverLayer) @@ -2701,8 +2700,7 @@ describe("McpServer", () => { version: "1.0.0", protocols: [McpProtocol.v2026_07_28] }).pipe( - Layer.provide(stdioLayer), - Layer.provideMerge(HttpRouter.layer) + Layer.provide(stdioLayer) ) ) yield* Deferred.succeed(serverReady, Context.get(context, McpServer.McpServer)) diff --git a/packages/effect/test/http/HttpRouter.test.ts b/packages/effect/test/http/HttpRouter.test.ts index 39ffdd002c1..e94318e66cf 100644 --- a/packages/effect/test/http/HttpRouter.test.ts +++ b/packages/effect/test/http/HttpRouter.test.ts @@ -1,6 +1,6 @@ -import { assert, describe, it } from "@effect/vitest" -import { Effect, Layer, Option } from "effect" -import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/http" +import { assert, describe, expect, it } from "@effect/vitest" +import { Cause, Effect, Exit, Layer, Option } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" const echoUrl = (request: HttpServerRequest.HttpServerRequest) => Effect.succeed(HttpServerResponse.text(request.url)) @@ -22,6 +22,75 @@ const fetchText = (app: Layer.Layer, path: ) describe("HttpRouter", () => { + it("isolates toWebHandler routes even with a shared memo map", async () => { + const memoMap = Layer.makeMemoMapUnsafe() + const publicHandler = HttpRouter.toWebHandler( + HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), + { memoMap, disableLogger: true } + ) + const internalHandler = HttpRouter.toWebHandler( + HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), + { memoMap, disableLogger: true } + ) + try { + const status = (handler: typeof publicHandler.handler, path: string) => + handler(new Request("http://localhost" + path)).then((response) => response.status) + assert.strictEqual(await status(publicHandler.handler, "/public"), 200) + assert.strictEqual(await status(internalHandler.handler, "/internal"), 200) + assert.strictEqual(await status(publicHandler.handler, "/internal"), 404) + assert.strictEqual(await status(internalHandler.handler, "/public"), 404) + } finally { + await Promise.all([publicHandler.dispose(), internalHandler.dispose()]) + } + }) + + it.effect("isolates toHttpEffect routes with a shared memo map", () => + Effect.gen(function*() { + const memoMap = Layer.makeMemoMapUnsafe() + const publicHandler = yield* HttpRouter.toHttpEffect( + HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), + { memoMap } + ) + const internalHandler = yield* HttpRouter.toHttpEffect( + HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), + { memoMap } + ) + const status = (handler: typeof publicHandler, path: string) => + handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost" + path)) + ), + Effect.map((response) => response.status) + ) + assert.strictEqual(yield* status(publicHandler, "/public"), 200) + assert.strictEqual(yield* status(internalHandler, "/internal"), 200) + const publicMiss = yield* Effect.exit(status(publicHandler, "/internal")) + const internalMiss = yield* Effect.exit(status(internalHandler, "/public")) + assert.isTrue(Exit.isFailure(publicMiss)) + assert.isTrue(Exit.isFailure(internalMiss)) + if (Exit.isFailure(publicMiss)) assert.match(Cause.pretty(publicMiss.cause), /RouteNotFound/) + if (Exit.isFailure(internalMiss)) assert.match(Cause.pretty(internalMiss.cause), /RouteNotFound/) + }).pipe(Effect.scoped)) + + it("rejects an app that outputs a foreign router in toWebHandler", async () => { + const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) + const { dispose, handler } = HttpRouter.toWebHandler(app as Layer.Layer, { disableLogger: true }) + try { + await expect(handler(new Request("http://localhost/"))).rejects.toThrow(/foreign.*router/i) + } finally { + await dispose() + } + }) + + it.effect("rejects an app that outputs a foreign router in toHttpEffect", () => + Effect.gen(function*() { + const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) + const exit = yield* Effect.exit(HttpRouter.toHttpEffect(app as Layer.Layer)) + assert.isTrue(Exit.isFailure(exit)) + if (Exit.isFailure(exit)) assert.match(Cause.pretty(exit.cause), /foreign.*router/i) + }).pipe(Effect.scoped)) + it("normalizes the prefix stored by prefixRoute", () => { const route = HttpRouter.prefixRoute( HttpRouter.route("GET", "/users", HttpServerResponse.text("ok")), diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index 5fc46255b33..ce6517a185c 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -3,6 +3,26 @@ import { HttpRouter, type HttpServerError, HttpServerResponse } from "effect/htt import { describe, expect, it } from "tstyche" describe("HttpRouter", () => { + describe("router ownership", () => { + it("does not expose reusable router constructors", () => { + // @ts-expect-error Router creation belongs to the entrypoints + void HttpRouter.layer + // @ts-expect-error Router creation belongs to the entrypoints + void HttpRouter.make + }) + + it("does not accept app layers that output HttpRouter", () => { + const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) + + // @ts-expect-error The router must be owned by serve + void HttpRouter.serve(app) + // @ts-expect-error The router must be owned by toWebHandler + void HttpRouter.toWebHandler(app) + // @ts-expect-error The router must be owned by toHttpEffect + void HttpRouter.toHttpEffect(app) + }) + }) + describe("middleware", () => { it("provides handled request errors", () => { class MyError { diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 35f6e902b90..66bb8ef5f58 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { NodeWS } from "@effect/platform-node/NodeSocket" import { assert, describe, expect, it } from "@effect/vitest" -import { ByteSize, Effect, Option } from "effect" +import { ByteSize, Cause, Effect, Exit, Option } from "effect" import * as Duration from "effect/Duration" import * as Fiber from "effect/Fiber" import { constVoid } from "effect/Function" @@ -47,6 +47,20 @@ const IdParams = Schema.Struct({ const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { + it.effect("rejects a foreign router output from serve", () => + Effect.gen(function*() { + const foreign = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) + const exit = yield* Effect.exit( + Layer.build( + HttpRouter.serve(foreign as Layer.Layer, { disableLogger: true, disableListenLog: true }).pipe( + Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })) + ) + ) + ) + assert.isTrue(Exit.isFailure(exit)) + if (Exit.isFailure(exit)) assert.match(Cause.pretty(exit.cause), /foreign.*router/i) + }).pipe(Effect.scoped)) + it.effect("keeps routes isolated between independent servers", () => Effect.gen(function*() { const publicServer = Http.createServer() From 6034d82fc8a5342998ebad9fa165227b0301fca7 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:40:14 +0000 Subject: [PATCH 09/35] feat(http): make HttpRouter entrypoints own their router Remove HttpRouter.layer and HttpRouter.make, reject app layers that output HttpRouter, and add a memoMap option to toHttpEffect. --- .changeset/isolate-http-serve-routers.md | 8 +- .../effect__platform__HttpLayerRouter.yaml | 8 +- .../effect__platform__HttpRouter.yaml | 4 +- packages/effect/src/http/HttpRouter.ts | 113 +++++++++++------- 4 files changed, 79 insertions(+), 54 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 8ff876da025..194f968a245 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,6 +2,10 @@ "effect": patch --- -`HttpRouter.serve` now uses a fresh router per server, so listeners no longer share routes by default. Apps that supply the same router can still share routes. +`HttpRouter.serve`, `toWebHandler`, and `toHttpEffect` now each create their own router, so servers and handlers in one layer graph, or sharing a `memoMap`, no longer share routes. `toHttpEffect` also accepts a `memoMap` option. -Routes registered on an `HttpRouter.layer` provided outside `serve` are no longer implicitly served and may return 404. Put the route-producing layer inside the app passed to `serve`, for example `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`, where `YourRpcHandlers` is your RPC application layer. +### Breaking changes + +- `HttpRouter.layer` and `HttpRouter.make` were removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. +- App layers passed to an entrypoint can no longer output `HttpRouter`; this is a type error, and a defect at build time. +- Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. diff --git a/migration/annotations/effect__platform__HttpLayerRouter.yaml b/migration/annotations/effect__platform__HttpLayerRouter.yaml index c77bd7663f2..3664c2770f5 100644 --- a/migration/annotations/effect__platform__HttpLayerRouter.yaml +++ b/migration/annotations/effect__platform__HttpLayerRouter.yaml @@ -11,8 +11,8 @@ replacement: "FindMyWay.PathInput" note: "Import FindMyWay from effect/http." "@effect/platform/HttpLayerRouter#make": - replacement: "HttpRouter.make" - note: "The layer-oriented router became the sole v4 HttpRouter implementation." + replacement: "none" + note: "HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each create their own router; pass route layers to one of them." "@effect/platform/HttpLayerRouter#MiddlewareTypeId": replacement: "none" note: "The middleware type id is internal in v4; use HttpRouter.Middleware." @@ -62,8 +62,8 @@ replacement: "HttpRouter.addAll" note: "Moved to the consolidated HttpRouter; it still registers route values through a Layer and supports a prefix option." "@effect/platform/HttpLayerRouter#layer": - replacement: "HttpRouter.layer" - note: "Use the layer for the consolidated HttpRouter service." + replacement: "none" + note: "HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each provide their own router; remove the router layer and pass route layers to one of them." "@effect/platform/HttpLayerRouter#toHttpEffect": replacement: "HttpRouter.toHttpEffect" note: "Moved to the consolidated HttpRouter; route-not-found failures now use HttpServerError.HttpServerError." diff --git a/migration/annotations/effect__platform__HttpRouter.yaml b/migration/annotations/effect__platform__HttpRouter.yaml index 7fdd24d99c2..424ab4b75a8 100644 --- a/migration/annotations/effect__platform__HttpRouter.yaml +++ b/migration/annotations/effect__platform__HttpRouter.yaml @@ -23,8 +23,8 @@ replacement: "HttpRouter.RouterConfig" note: "The FiberRef became a Context.Reference containing Partial." "@effect/platform/HttpRouter#Default": - replacement: "HttpRouter.HttpRouter + HttpRouter.layer" - note: "Custom/default router tags were removed; v4 provides one router service." + replacement: "HttpRouter.HttpRouter" + note: "Custom/default router tags were removed; route layers require HttpRouter.HttpRouter, which HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect provide." "@effect/platform/HttpRouter#empty": replacement: "Layer.empty" note: "There is no immutable empty router; use an empty registration layer." diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index b259dc8f24b..d471558f64f 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -109,19 +109,7 @@ export const HttpRouter: Context.Service = Context.Servi "effect/http/HttpRouter" ) -/** - * Constructs an empty `HttpRouter` service. - * - * **Details** - * - * The returned router accepts route and middleware registrations and later routes - * the current `HttpServerRequest` to the matching `HttpServerResponse`. - * - * @stability unstable - * @category constructors - * @since 4.0.0 - */ -export const make = Effect.gen(function*() { +const make = Effect.gen(function*() { const router = FindMyWay.make>(yield* RouterConfig) const middleware = new Set() @@ -579,15 +567,6 @@ export const addAll = >, EX = never yield* router.addAll(toAdd) })) -/** - * Layer that provides a newly constructed `HttpRouter`. - * - * @stability unstable - * @category layers - * @since 4.0.0 - */ -export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) - /** * Builds an application layer with a router and returns the router as an HTTP * handler effect. @@ -596,14 +575,18 @@ export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) * * The returned effect handles the current `HttpServerRequest` in the current * `Scope`; route request markers are converted into the ordinary requirements of - * the returned handler. + * the returned handler. Each call creates its own router, even when `memoMap` + * is shared with other builds. * * @stability unstable * @category converting * @since 4.0.0 */ export const toHttpEffect = ( - appLayer: Layer.Layer + appLayer: Layer.Layer & OwnedRouter, + options?: { + readonly memoMap?: Layer.MemoMap | undefined + } ): Effect.Effect< Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -614,12 +597,45 @@ export const toHttpEffect = ( Exclude, HttpRouter> | Scope.Scope > => Effect.gen(function*() { - const context = yield* Layer.build(Layer.provideMerge(appLayer, layer)) + const app = provideRouter(appLayer, undefined) + const context = yield* (options?.memoMap + ? Layer.buildWithMemoMap(app, options.memoMap, yield* Effect.scope) + : Layer.build(app)) const router = Context.get(context, HttpRouter) // @effect-diagnostics effect/returnEffectInGen:off return router.asHttpEffect() }) as any +/** + * Rejects app layers that output `HttpRouter`, since the entrypoint owns the + * router. + */ +type OwnedRouter = HttpRouter extends A ? + "The HttpRouter is provided by the entrypoint; remove it from the app layer" + : unknown + +/** + * Builds the app layer with a new router, so apps never share a router through + * layer memoization. + */ +const provideRouter = ( + appLayer: Layer.Layer, + routerConfig: Partial | undefined +): Layer.Layer> => + Layer.fromBuild((memoMap, scope) => + Effect.gen(function*() { + const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) + const context = yield* Effect.provideService(Layer.buildWithMemoMap(appLayer, memoMap, scope), HttpRouter, router) + const appRouter = Context.getOrUndefined(context as Context.Context, HttpRouter) + if (appRouter !== undefined && appRouter !== router) { + return yield* Effect.die( + new Error("The app layer provided a foreign HttpRouter; the router is owned by the HttpRouter entrypoint") + ) + } + return Context.add(context, HttpRouter, router) + }) + ) + const RouteTypeId = "~effect/http/HttpRouter/Route" /** @@ -1270,7 +1286,7 @@ export const provideRequest = * @since 4.0.0 */ export const serve = | Request.Only<"GlobalRequires", R>>( - appLayer: Layer.Layer, + appLayer: Layer.Layer & OwnedRouter, options?: { readonly routerConfig?: Partial | undefined readonly disableLogger?: boolean | undefined @@ -1305,17 +1321,22 @@ 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) - }).pipe( - Layer.unwrap, - Layer.provideMerge(appLayer), - Layer.provide(Layer.fresh(RouterLayer)), + const app = provideRouter(appLayer, options?.routerConfig) + return Layer.fromBuild((memoMap, scope) => + Effect.gen(function*() { + const context = yield* Layer.buildWithMemoMap(app, memoMap, scope) + const handler = Context.get(context, HttpRouter).asHttpEffect() + yield* Effect.provideContext( + Layer.buildWithMemoMap( + middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler), + memoMap, + scope + ), + context + ) + return Context.omit(HttpRouter)(context) + }) + ).pipe( options?.disableListenLog ? identity : HttpServer.withLogAddress ) as any } @@ -1351,7 +1372,7 @@ export const toWebHandler = < HR = Exclude | Request.Only<"GlobalRequires", R>, A>, ReqR = Exclude >( - appLayer: Layer.Layer, + appLayer: Layer.Layer & OwnedRouter, options?: { readonly memoMap?: Layer.MemoMap | undefined readonly routerConfig?: Partial | undefined @@ -1390,12 +1411,12 @@ export const toWebHandler = < 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 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( + provideRouter(appLayer, options?.routerConfig) as Layer.Layer, + { + toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()), + middleware, + memoMap: options?.memoMap + } + ) } From 2aae4ee2c172efd99a7d2d1f9098ab9588d7adf1 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:45:46 +0000 Subject: [PATCH 10/35] docs(migration): point router layer users at HttpRouter entrypoints --- migration/v3-to-v4.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index fb13934f5e8..5ccd01a10a2 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -7133,9 +7133,9 @@ effect/rpc/Utils (barrel: effect/rpc) - `HttpLayerRouter.cors` -> `HttpRouter.cors`: HttpLayerRouter was consolidated into v4 HttpRouter. -- `HttpLayerRouter.layer` -> `HttpRouter.layer`: Use the layer for the consolidated HttpRouter service. +- `HttpLayerRouter.layer` -> `none`: HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each provide their own router; remove the router layer and pass route layers to one of them. -- `HttpLayerRouter.make` -> `HttpRouter.make`: The layer-oriented router became the sole v4 HttpRouter implementation. +- `HttpLayerRouter.make` -> `none`: HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each create their own router; pass route layers to one of them. - `HttpLayerRouter.schemaJson` -> `HttpRouter.schemaJson`: Moved to the consolidated router with v4 Schema and error types. @@ -7177,7 +7177,7 @@ effect/rpc/Utils (barrel: effect/rpc) ### `@effect/platform/HttpRouter` -- `HttpRouter.Default` -> `HttpRouter.HttpRouter + HttpRouter.layer`: Custom/default router tags were removed; v4 provides one router service. +- `HttpRouter.Default` -> `HttpRouter.HttpRouter`: Custom/default router tags were removed; route layers require HttpRouter.HttpRouter, which HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect provide. - `HttpRouter.HttpRouter` -> `HttpRouter.HttpRouter`: The name remains, but now denotes the mutable layer-oriented registration service. From 49ade9c0dec87d7ba1462a31e93ff544ac52da64 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 05:50:20 +0000 Subject: [PATCH 11/35] test(http): repair router ownership type contracts --- .../test/ai/McpServer/TestUtils/McpHttpHarness.ts | 5 ++++- packages/effect/typetest/http/HttpRouter.tst.ts | 12 +++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts index e08c8e09232..70b0380bfcc 100644 --- a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts +++ b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts @@ -14,7 +14,10 @@ export const makeHttpHarness = Effect.fnUntraced(function*( } ) { const appLayer = options?.routerLayer ? Layer.merge(serverLayer, options.routerLayer) : serverLayer - const { dispose, handler } = HttpRouter.toWebHandler(appLayer, { disableLogger: true }) + const { dispose, handler } = HttpRouter.toWebHandler( + appLayer as Layer.Layer, + { disableLogger: true } + ) yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) const responses: Array = [] let sessionId: string | null = null diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index ce6517a185c..a025a4c5df5 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -5,20 +5,18 @@ import { describe, expect, it } from "tstyche" describe("HttpRouter", () => { describe("router ownership", () => { it("does not expose reusable router constructors", () => { - // @ts-expect-error Router creation belongs to the entrypoints - void HttpRouter.layer - // @ts-expect-error Router creation belongs to the entrypoints - void HttpRouter.make + expect().type.not.toHaveProperty("layer") + expect().type.not.toHaveProperty("make") }) it("does not accept app layers that output HttpRouter", () => { const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - // @ts-expect-error The router must be owned by serve + // @ts-expect-error remove it from the app layer void HttpRouter.serve(app) - // @ts-expect-error The router must be owned by toWebHandler + // @ts-expect-error remove it from the app layer void HttpRouter.toWebHandler(app) - // @ts-expect-error The router must be owned by toHttpEffect + // @ts-expect-error remove it from the app layer void HttpRouter.toHttpEffect(app) }) }) From 54f614e38cf649767bf6f8c7d1c4d9f1f929f69d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 06:03:18 +0000 Subject: [PATCH 12/35] test(http): pin shared route layer isolation and fresh workaround --- packages/effect/test/http/HttpRouter.test.ts | 53 +++++++++++++++++++ .../effect/typetest/http/HttpRouter.tst.ts | 22 ++++++++ .../platform/node/test/NodeHttpServer.test.ts | 53 ++++++++++++++++++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/effect/test/http/HttpRouter.test.ts b/packages/effect/test/http/HttpRouter.test.ts index e94318e66cf..6a4eb4070c5 100644 --- a/packages/effect/test/http/HttpRouter.test.ts +++ b/packages/effect/test/http/HttpRouter.test.ts @@ -22,6 +22,59 @@ const fetchText = (app: Layer.Layer, path: ) describe("HttpRouter", () => { + it("registers fresh shared routes on both web handlers with a shared memo map", async () => { + const memoMap = Layer.makeMemoMapUnsafe() + const health = HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")) + const publicHandler = HttpRouter.toWebHandler( + Layer.mergeAll(HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), Layer.fresh(health)), + { memoMap, disableLogger: true } + ) + const internalHandler = HttpRouter.toWebHandler( + Layer.mergeAll(HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), Layer.fresh(health)), + { memoMap, disableLogger: true } + ) + try { + const status = (handler: typeof publicHandler.handler, path: string) => + handler(new Request("http://localhost" + path)).then((response) => response.status) + assert.strictEqual(await status(publicHandler.handler, "/health"), 200) + assert.strictEqual(await status(internalHandler.handler, "/health"), 200) + assert.strictEqual(await status(publicHandler.handler, "/internal"), 404) + assert.strictEqual(await status(internalHandler.handler, "/public"), 404) + } finally { + await Promise.all([publicHandler.dispose(), internalHandler.dispose()]) + } + }) + + it.effect("registers fresh shared routes on both HTTP effects with a shared memo map", () => + Effect.gen(function*() { + const memoMap = Layer.makeMemoMapUnsafe() + const health = HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")) + const publicHandler = yield* HttpRouter.toHttpEffect( + Layer.mergeAll(HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), Layer.fresh(health)), + { memoMap } + ) + const internalHandler = yield* HttpRouter.toHttpEffect( + Layer.mergeAll(HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), Layer.fresh(health)), + { memoMap } + ) + const status = (handler: typeof publicHandler, path: string) => + handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost" + path)) + ), + Effect.map((response) => response.status) + ) + assert.strictEqual(yield* status(publicHandler, "/health"), 200) + assert.strictEqual(yield* status(internalHandler, "/health"), 200) + const publicMiss = yield* Effect.exit(status(publicHandler, "/internal")) + const internalMiss = yield* Effect.exit(status(internalHandler, "/public")) + assert.isTrue(Exit.isFailure(publicMiss)) + assert.isTrue(Exit.isFailure(internalMiss)) + if (Exit.isFailure(publicMiss)) assert.match(Cause.pretty(publicMiss.cause), /RouteNotFound/) + if (Exit.isFailure(internalMiss)) assert.match(Cause.pretty(internalMiss.cause), /RouteNotFound/) + }).pipe(Effect.scoped)) + it("isolates toWebHandler routes even with a shared memo map", async () => { const memoMap = Layer.makeMemoMapUnsafe() const publicHandler = HttpRouter.toWebHandler( diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index a025a4c5df5..04cad60402a 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -19,6 +19,28 @@ describe("HttpRouter", () => { // @ts-expect-error remove it from the app layer void HttpRouter.toHttpEffect(app) }) + + it("accepts app layers with any output", () => { + const app = {} as Layer.Layer + expect(HttpRouter.serve).type.toBeCallableWith(app) + expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) + expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) + }) + + it("rejects app layers with unknown output", () => { + const app = {} as Layer.Layer + // @ts-expect-error remove it from the app layer + void HttpRouter.serve(app) + // @ts-expect-error remove it from the app layer + void HttpRouter.toWebHandler(app) + // @ts-expect-error remove it from the app layer + void HttpRouter.toHttpEffect(app) + }) + + it("accepts a narrowed generic wrapper", () => { + const serve = (app: Layer.Layer) => HttpRouter.serve(app) + expect(serve).type.toBeCallableWith(Layer.empty as Layer.Layer) + }) }) describe("middleware", () => { diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 66bb8ef5f58..41b8d8e92a4 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { NodeWS } from "@effect/platform-node/NodeSocket" import { assert, describe, expect, it } from "@effect/vitest" -import { ByteSize, Cause, Effect, Exit, Option } from "effect" +import { ByteSize, Cause, Context, Effect, Exit, Option } from "effect" import * as Duration from "effect/Duration" import * as Fiber from "effect/Fiber" import { constVoid } from "effect/Function" @@ -47,6 +47,57 @@ const IdParams = Schema.Struct({ const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { + const sharedHealthServers = (health: Layer.Layer) => + Effect.gen(function*() { + const publicServer = Http.createServer() + const internalServer = Http.createServer() + const serve = (server: Http.Server, route: Layer.Layer) => + HttpRouter.serve(Layer.mergeAll(route, health), { disableListenLog: true, disableLogger: true }).pipe( + Layer.provide(NodeHttpServer.layer(() => server, { port: 0 })) + ) + + yield* Layer.build(Layer.mergeAll( + serve(publicServer, HttpRouter.add("GET", "/public", HttpServerResponse.text("public"))), + serve(internalServer, HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal"))) + )) + + const status = (server: Http.Server, path: string) => + Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + path).then((response) => response.status)) + assert.strictEqual(yield* status(publicServer, "/public"), 200) + assert.strictEqual(yield* status(internalServer, "/internal"), 200) + assert.strictEqual(yield* status(publicServer, "/internal"), 404) + assert.strictEqual(yield* status(internalServer, "/public"), 404) + return { publicServer, internalServer, status } + }) + + it.effect("does not leak routes when servers share a route layer", () => + sharedHealthServers(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")))) + + it.effect("registers a shared fresh route layer on both servers", () => + Effect.gen(function*() { + const health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy"))) + const { internalServer, publicServer, status } = yield* sharedHealthServers(health) + assert.strictEqual(yield* status(publicServer, "/health"), 200) + assert.strictEqual(yield* status(internalServer, "/health"), 200) + })) + + it.effect("shares dependencies outside a fresh route layer", () => + Effect.gen(function*() { + class Counter extends Context.Service()("SharedHealthCounter") {} + let acquisitions = 0 + const counter = Layer.sync(Counter, () => ++acquisitions) + const healthRoute = Layer.effectDiscard(Effect.gen(function*() { + const value = yield* Counter + const router = yield* HttpRouter.HttpRouter + yield* router.add("GET", "/health", HttpServerResponse.text(String(value))) + })) + const health = Layer.fresh(healthRoute).pipe(Layer.provide(counter)) + const { internalServer, publicServer, status } = yield* sharedHealthServers(health) + assert.strictEqual(yield* status(publicServer, "/health"), 200) + assert.strictEqual(yield* status(internalServer, "/health"), 200) + assert.strictEqual(acquisitions, 1) + })) + it.effect("rejects a foreign router output from serve", () => Effect.gen(function*() { const foreign = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) From a57855e9c96c949c42969e6ac54f1e9b16f9a268 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 06:06:05 +0000 Subject: [PATCH 13/35] fix(http): exempt any app layers and document shared route layers --- .changeset/isolate-http-serve-routers.md | 3 ++- packages/effect/src/http/HttpRouter.ts | 32 ++++++++++++++++++------ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 194f968a245..98cb8d8c62d 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -7,5 +7,6 @@ ### Breaking changes - `HttpRouter.layer` and `HttpRouter.make` were removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. -- App layers passed to an entrypoint can no longer output `HttpRouter`; this is a type error, and a defect at build time. +- App layers passed to an entrypoint can no longer output `HttpRouter`; this is a type error, and a defect at build time. Generic wrappers that pass a `Layer.Layer` with an unconstrained `A` must narrow the layer, since the compiler cannot rule out an `HttpRouter` output. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. +- A route layer used by more than one entrypoint in the same layer graph, or with a shared `memoMap`, is registered only once and returns 404 on the others. Wrap it in `Layer.fresh`, for example `const Health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.empty()))`, and provide shared resources such as database pools outside `Layer.fresh` so they are still built once. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index d471558f64f..324aebd59d1 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -575,8 +575,13 @@ export const addAll = >, EX = never * * The returned effect handles the current `HttpServerRequest` in the current * `Scope`; route request markers are converted into the ordinary requirements of - * the returned handler. Each call creates its own router, even when `memoMap` - * is shared with other builds. + * the returned handler. Each call creates its own router. + * + * **Gotchas** + * + * A route layer shared with another entrypoint in the same layer graph, or + * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, + * and provide shared resources outside `Layer.fresh` so they are built once. * * @stability unstable * @category converting @@ -608,10 +613,10 @@ export const toHttpEffect = ( /** * Rejects app layers that output `HttpRouter`, since the entrypoint owns the - * router. + * router. `any` outputs are not checked. */ -type OwnedRouter = HttpRouter extends A ? - "The HttpRouter is provided by the entrypoint; remove it from the app layer" +type OwnedRouter = 0 extends 1 & A ? unknown + : HttpRouter extends A ? "The HttpRouter is provided by the entrypoint; remove it from the app layer" : unknown /** @@ -1279,7 +1284,13 @@ export const provideRequest = ) /** - * Runs the provided application layer as an HTTP server. + * Runs the provided application layer as an HTTP server with its own router. + * + * **Gotchas** + * + * A route layer shared with another entrypoint in the same layer graph, or + * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, + * and provide shared resources outside `Layer.fresh` so they are built once. * * @stability unstable * @category layers @@ -1353,7 +1364,14 @@ export const serve = | Request.On * handler is created rather than on the first request. A layer that performs * asynchronous work while building may still be in progress when the first * request arrives, in which case that request waits for the build to finish. - * If the build fails, every request rejects with the build error. + * If the build fails, every request rejects with the build error. Each call + * creates its own router. + * + * **Gotchas** + * + * A route layer shared with another entrypoint in the same layer graph, or + * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, + * and provide shared resources outside `Layer.fresh` so they are built once. * * @stability unstable * @category converting From c0f47292da5ad6dff66249d438e33a44fe43a7ee Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 06:49:32 +0000 Subject: [PATCH 14/35] test(http): require unrestricted router app outputs and public make --- .../effect/typetest/http/HttpRouter.tst.ts | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index 04cad60402a..9618b68d334 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -4,20 +4,16 @@ import { describe, expect, it } from "tstyche" describe("HttpRouter", () => { describe("router ownership", () => { - it("does not expose reusable router constructors", () => { + it("exposes make but not the reusable router layer", () => { expect().type.not.toHaveProperty("layer") - expect().type.not.toHaveProperty("make") + expect>().type.toBe() }) - it("does not accept app layers that output HttpRouter", () => { + it("accepts app layers that output HttpRouter", () => { const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - - // @ts-expect-error remove it from the app layer - void HttpRouter.serve(app) - // @ts-expect-error remove it from the app layer - void HttpRouter.toWebHandler(app) - // @ts-expect-error remove it from the app layer - void HttpRouter.toHttpEffect(app) + expect(HttpRouter.serve).type.toBeCallableWith(app) + expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) + expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) }) it("accepts app layers with any output", () => { @@ -27,19 +23,20 @@ describe("HttpRouter", () => { expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) }) - it("rejects app layers with unknown output", () => { + it("accepts app layers with unknown output", () => { const app = {} as Layer.Layer - // @ts-expect-error remove it from the app layer - void HttpRouter.serve(app) - // @ts-expect-error remove it from the app layer - void HttpRouter.toWebHandler(app) - // @ts-expect-error remove it from the app layer - void HttpRouter.toHttpEffect(app) + expect(HttpRouter.serve).type.toBeCallableWith(app) + expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) + expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) }) - it("accepts a narrowed generic wrapper", () => { - const serve = (app: Layer.Layer) => HttpRouter.serve(app) - expect(serve).type.toBeCallableWith(Layer.empty as Layer.Layer) + it("accepts unconstrained generic wrappers", () => { + const serve = (app: Layer.Layer) => HttpRouter.serve(app) + const toWebHandler = (app: Layer.Layer) => HttpRouter.toWebHandler(app) + const toHttpEffect = (app: Layer.Layer) => HttpRouter.toHttpEffect(app) + expect(serve).type.toBeCallableWith(Layer.empty) + expect(toWebHandler).type.toBeCallableWith(Layer.empty) + expect(toHttpEffect).type.toBeCallableWith(Layer.empty) }) }) From cc81566f9793bdd076d5d016e81708cf690fea46 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 06:52:16 +0000 Subject: [PATCH 15/35] fix(http): restore HttpRouter.make and drop app output type constraint --- .changeset/isolate-http-serve-routers.md | 4 +-- .../effect__platform__HttpLayerRouter.yaml | 4 +-- migration/v3-to-v4.md | 2 +- packages/effect/src/http/HttpRouter.ts | 34 ++++++++++++------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 98cb8d8c62d..60bba64410a 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -6,7 +6,7 @@ ### Breaking changes -- `HttpRouter.layer` and `HttpRouter.make` were removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. -- App layers passed to an entrypoint can no longer output `HttpRouter`; this is a type error, and a defect at build time. Generic wrappers that pass a `Layer.Layer` with an unconstrained `A` must narrow the layer, since the compiler cannot rule out an `HttpRouter` output. +- `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. +- An app layer passed to an entrypoint that outputs a different `HttpRouter`, for example one built with `HttpRouter.make`, now fails with a defect at build time. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. - A route layer used by more than one entrypoint in the same layer graph, or with a shared `memoMap`, is registered only once and returns 404 on the others. Wrap it in `Layer.fresh`, for example `const Health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.empty()))`, and provide shared resources such as database pools outside `Layer.fresh` so they are still built once. diff --git a/migration/annotations/effect__platform__HttpLayerRouter.yaml b/migration/annotations/effect__platform__HttpLayerRouter.yaml index 3664c2770f5..150910fae31 100644 --- a/migration/annotations/effect__platform__HttpLayerRouter.yaml +++ b/migration/annotations/effect__platform__HttpLayerRouter.yaml @@ -11,8 +11,8 @@ replacement: "FindMyWay.PathInput" note: "Import FindMyWay from effect/http." "@effect/platform/HttpLayerRouter#make": - replacement: "none" - note: "HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each create their own router; pass route layers to one of them." + replacement: "HttpRouter.make" + note: "The layer-oriented router became the sole v4 HttpRouter implementation." "@effect/platform/HttpLayerRouter#MiddlewareTypeId": replacement: "none" note: "The middleware type id is internal in v4; use HttpRouter.Middleware." diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index 5ccd01a10a2..647aabfc1d7 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -7135,7 +7135,7 @@ effect/rpc/Utils (barrel: effect/rpc) - `HttpLayerRouter.layer` -> `none`: HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each provide their own router; remove the router layer and pass route layers to one of them. -- `HttpLayerRouter.make` -> `none`: HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each create their own router; pass route layers to one of them. +- `HttpLayerRouter.make` -> `HttpRouter.make`: The layer-oriented router became the sole v4 HttpRouter implementation. - `HttpLayerRouter.schemaJson` -> `HttpRouter.schemaJson`: Moved to the consolidated router with v4 Schema and error types. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 324aebd59d1..0bdd3ff4a4d 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -109,7 +109,25 @@ export const HttpRouter: Context.Service = Context.Servi "effect/http/HttpRouter" ) -const make = Effect.gen(function*() { +/** + * Constructs an empty `HttpRouter` service. + * + * **Details** + * + * The returned router accepts route and middleware registrations and later routes + * the current `HttpServerRequest` to the matching `HttpServerResponse`. + * + * **Gotchas** + * + * `serve`, `toWebHandler`, and `toHttpEffect` create their own router. Use + * `make` only for custom integrations; an app layer passed to an entrypoint + * that outputs a different router fails with a defect. + * + * @stability unstable + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.gen(function*() { const router = FindMyWay.make>(yield* RouterConfig) const middleware = new Set() @@ -588,7 +606,7 @@ export const addAll = >, EX = never * @since 4.0.0 */ export const toHttpEffect = ( - appLayer: Layer.Layer & OwnedRouter, + appLayer: Layer.Layer, options?: { readonly memoMap?: Layer.MemoMap | undefined } @@ -611,14 +629,6 @@ export const toHttpEffect = ( return router.asHttpEffect() }) as any -/** - * Rejects app layers that output `HttpRouter`, since the entrypoint owns the - * router. `any` outputs are not checked. - */ -type OwnedRouter = 0 extends 1 & A ? unknown - : HttpRouter extends A ? "The HttpRouter is provided by the entrypoint; remove it from the app layer" - : unknown - /** * Builds the app layer with a new router, so apps never share a router through * layer memoization. @@ -1297,7 +1307,7 @@ export const provideRequest = * @since 4.0.0 */ export const serve = | Request.Only<"GlobalRequires", R>>( - appLayer: Layer.Layer & OwnedRouter, + appLayer: Layer.Layer, options?: { readonly routerConfig?: Partial | undefined readonly disableLogger?: boolean | undefined @@ -1390,7 +1400,7 @@ export const toWebHandler = < HR = Exclude | Request.Only<"GlobalRequires", R>, A>, ReqR = Exclude >( - appLayer: Layer.Layer & OwnedRouter, + appLayer: Layer.Layer, options?: { readonly memoMap?: Layer.MemoMap | undefined readonly routerConfig?: Partial | undefined From 5657c33dfd3859672975d77bef970c55d085fec2 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 07:37:31 +0000 Subject: [PATCH 16/35] test(http): accept app router outputs without serving foreign routes --- packages/effect/test/http/HttpRouter.test.ts | 47 +++++++++++++++---- .../platform/node/test/NodeHttpServer.test.ts | 29 ++++++++---- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/effect/test/http/HttpRouter.test.ts b/packages/effect/test/http/HttpRouter.test.ts index 6a4eb4070c5..450a95301af 100644 --- a/packages/effect/test/http/HttpRouter.test.ts +++ b/packages/effect/test/http/HttpRouter.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, expect, it } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { Cause, Effect, Exit, Layer, Option } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" @@ -126,22 +126,49 @@ describe("HttpRouter", () => { if (Exit.isFailure(internalMiss)) assert.match(Cause.pretty(internalMiss.cause), /RouteNotFound/) }).pipe(Effect.scoped)) - it("rejects an app that outputs a foreign router in toWebHandler", async () => { - const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - const { dispose, handler } = HttpRouter.toWebHandler(app as Layer.Layer, { disableLogger: true }) + it("serves its own router when the app outputs another router in toWebHandler", async () => { + const foreign = Layer.effect( + HttpRouter.HttpRouter, + Effect.gen(function*() { + yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) + const router = yield* HttpRouter.make + yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) + return router + }) + ) + const { dispose, handler } = HttpRouter.toWebHandler(foreign, { disableLogger: true }) try { - await expect(handler(new Request("http://localhost/"))).rejects.toThrow(/foreign.*router/i) + assert.strictEqual((await handler(new Request("http://localhost/own"))).status, 200) + assert.strictEqual((await handler(new Request("http://localhost/foreign"))).status, 404) } finally { await dispose() } }) - it.effect("rejects an app that outputs a foreign router in toHttpEffect", () => + it.effect("serves its own router when the app outputs another router in toHttpEffect", () => Effect.gen(function*() { - const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - const exit = yield* Effect.exit(HttpRouter.toHttpEffect(app as Layer.Layer)) - assert.isTrue(Exit.isFailure(exit)) - if (Exit.isFailure(exit)) assert.match(Cause.pretty(exit.cause), /foreign.*router/i) + const foreign = Layer.effect( + HttpRouter.HttpRouter, + Effect.gen(function*() { + yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) + const router = yield* HttpRouter.make + yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) + return router + }) + ) + const handler = yield* HttpRouter.toHttpEffect(foreign) + const status = (path: string) => + handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost" + path)) + ), + Effect.map((response) => response.status) + ) + assert.strictEqual(yield* status("/own"), 200) + const foreignMiss = yield* Effect.exit(status("/foreign")) + assert.isTrue(Exit.isFailure(foreignMiss)) + if (Exit.isFailure(foreignMiss)) assert.match(Cause.pretty(foreignMiss.cause), /RouteNotFound/) }).pipe(Effect.scoped)) it("normalizes the prefix stored by prefixRoute", () => { diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 41b8d8e92a4..03977608039 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { NodeWS } from "@effect/platform-node/NodeSocket" import { assert, describe, expect, it } from "@effect/vitest" -import { ByteSize, Cause, Context, Effect, Exit, Option } from "effect" +import { ByteSize, Context, Effect, Option } from "effect" import * as Duration from "effect/Duration" import * as Fiber from "effect/Fiber" import { constVoid } from "effect/Function" @@ -98,18 +98,27 @@ describe("HttpServer", () => { assert.strictEqual(acquisitions, 1) })) - it.effect("rejects a foreign router output from serve", () => + it.effect("serves its own router when the app outputs another router", () => Effect.gen(function*() { - const foreign = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - const exit = yield* Effect.exit( - Layer.build( - HttpRouter.serve(foreign as Layer.Layer, { disableLogger: true, disableListenLog: true }).pipe( - Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })) - ) + const server = Http.createServer() + const foreign = Layer.effect( + HttpRouter.HttpRouter, + Effect.gen(function*() { + yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) + const router = yield* HttpRouter.make + yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) + return router + }) + ) + yield* Layer.build( + HttpRouter.serve(foreign, { disableLogger: true, disableListenLog: true }).pipe( + Layer.provide(NodeHttpServer.layer(() => server, { port: 0 })) ) ) - assert.isTrue(Exit.isFailure(exit)) - if (Exit.isFailure(exit)) assert.match(Cause.pretty(exit.cause), /foreign.*router/i) + const status = (path: string) => + Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + path).then((response) => response.status)) + assert.strictEqual(yield* status("/own"), 200) + assert.strictEqual(yield* status("/foreign"), 404) }).pipe(Effect.scoped)) it.effect("keeps routes isolated between independent servers", () => From b51d57e9aeb63f2f4a3c5cf3f5bdaeb7bbe95d6a Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 07:40:15 +0000 Subject: [PATCH 17/35] fix(http): accept app router outputs without replacing owned router --- .changeset/isolate-http-serve-routers.md | 2 +- packages/effect/src/http/HttpRouter.ts | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 60bba64410a..6524842f26b 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -7,6 +7,6 @@ ### Breaking changes - `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. -- An app layer passed to an entrypoint that outputs a different `HttpRouter`, for example one built with `HttpRouter.make`, now fails with a defect at build time. +- An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. - A route layer used by more than one entrypoint in the same layer graph, or with a shared `memoMap`, is registered only once and returns 404 on the others. Wrap it in `Layer.fresh`, for example `const Health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.empty()))`, and provide shared resources such as database pools outside `Layer.fresh` so they are still built once. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 0bdd3ff4a4d..d77fb0fa843 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -120,8 +120,8 @@ export const HttpRouter: Context.Service = Context.Servi * **Gotchas** * * `serve`, `toWebHandler`, and `toHttpEffect` create their own router. Use - * `make` only for custom integrations; an app layer passed to an entrypoint - * that outputs a different router fails with a defect. + * `make` for custom integrations. A router output by an app layer does not + * replace the router served by an entrypoint. * * @stability unstable * @category constructors @@ -641,12 +641,6 @@ const provideRouter = ( Effect.gen(function*() { const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) const context = yield* Effect.provideService(Layer.buildWithMemoMap(appLayer, memoMap, scope), HttpRouter, router) - const appRouter = Context.getOrUndefined(context as Context.Context, HttpRouter) - if (appRouter !== undefined && appRouter !== router) { - return yield* Effect.die( - new Error("The app layer provided a foreign HttpRouter; the router is owned by the HttpRouter entrypoint") - ) - } return Context.add(context, HttpRouter, router) }) ) From 97d92e51f74509738b2fa1707e16d8c035fc1471 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 07:44:24 +0000 Subject: [PATCH 18/35] test(http): focus router isolation coverage --- .../ai/McpServer/TestUtils/McpHttpHarness.ts | 5 +- packages/effect/test/http/HttpRouter.test.ts | 131 +----------------- .../effect/typetest/http/HttpRouter.tst.ts | 27 +--- .../platform/node/test/NodeHttpServer.test.ts | 55 ++------ 4 files changed, 14 insertions(+), 204 deletions(-) diff --git a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts index 70b0380bfcc..e08c8e09232 100644 --- a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts +++ b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts @@ -14,10 +14,7 @@ export const makeHttpHarness = Effect.fnUntraced(function*( } ) { const appLayer = options?.routerLayer ? Layer.merge(serverLayer, options.routerLayer) : serverLayer - const { dispose, handler } = HttpRouter.toWebHandler( - appLayer as Layer.Layer, - { disableLogger: true } - ) + const { dispose, handler } = HttpRouter.toWebHandler(appLayer, { disableLogger: true }) yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) const responses: Array = [] let sessionId: string | null = null diff --git a/packages/effect/test/http/HttpRouter.test.ts b/packages/effect/test/http/HttpRouter.test.ts index 450a95301af..6dc675df34b 100644 --- a/packages/effect/test/http/HttpRouter.test.ts +++ b/packages/effect/test/http/HttpRouter.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" -import { Cause, Effect, Exit, Layer, Option } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" +import { Effect, Layer, Option } from "effect" +import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/http" const echoUrl = (request: HttpServerRequest.HttpServerRequest) => Effect.succeed(HttpServerResponse.text(request.url)) @@ -22,59 +22,6 @@ const fetchText = (app: Layer.Layer, path: ) describe("HttpRouter", () => { - it("registers fresh shared routes on both web handlers with a shared memo map", async () => { - const memoMap = Layer.makeMemoMapUnsafe() - const health = HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")) - const publicHandler = HttpRouter.toWebHandler( - Layer.mergeAll(HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), Layer.fresh(health)), - { memoMap, disableLogger: true } - ) - const internalHandler = HttpRouter.toWebHandler( - Layer.mergeAll(HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), Layer.fresh(health)), - { memoMap, disableLogger: true } - ) - try { - const status = (handler: typeof publicHandler.handler, path: string) => - handler(new Request("http://localhost" + path)).then((response) => response.status) - assert.strictEqual(await status(publicHandler.handler, "/health"), 200) - assert.strictEqual(await status(internalHandler.handler, "/health"), 200) - assert.strictEqual(await status(publicHandler.handler, "/internal"), 404) - assert.strictEqual(await status(internalHandler.handler, "/public"), 404) - } finally { - await Promise.all([publicHandler.dispose(), internalHandler.dispose()]) - } - }) - - it.effect("registers fresh shared routes on both HTTP effects with a shared memo map", () => - Effect.gen(function*() { - const memoMap = Layer.makeMemoMapUnsafe() - const health = HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")) - const publicHandler = yield* HttpRouter.toHttpEffect( - Layer.mergeAll(HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), Layer.fresh(health)), - { memoMap } - ) - const internalHandler = yield* HttpRouter.toHttpEffect( - Layer.mergeAll(HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), Layer.fresh(health)), - { memoMap } - ) - const status = (handler: typeof publicHandler, path: string) => - handler.pipe( - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb(new Request("http://localhost" + path)) - ), - Effect.map((response) => response.status) - ) - assert.strictEqual(yield* status(publicHandler, "/health"), 200) - assert.strictEqual(yield* status(internalHandler, "/health"), 200) - const publicMiss = yield* Effect.exit(status(publicHandler, "/internal")) - const internalMiss = yield* Effect.exit(status(internalHandler, "/public")) - assert.isTrue(Exit.isFailure(publicMiss)) - assert.isTrue(Exit.isFailure(internalMiss)) - if (Exit.isFailure(publicMiss)) assert.match(Cause.pretty(publicMiss.cause), /RouteNotFound/) - if (Exit.isFailure(internalMiss)) assert.match(Cause.pretty(internalMiss.cause), /RouteNotFound/) - }).pipe(Effect.scoped)) - it("isolates toWebHandler routes even with a shared memo map", async () => { const memoMap = Layer.makeMemoMapUnsafe() const publicHandler = HttpRouter.toWebHandler( @@ -97,80 +44,6 @@ describe("HttpRouter", () => { } }) - it.effect("isolates toHttpEffect routes with a shared memo map", () => - Effect.gen(function*() { - const memoMap = Layer.makeMemoMapUnsafe() - const publicHandler = yield* HttpRouter.toHttpEffect( - HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), - { memoMap } - ) - const internalHandler = yield* HttpRouter.toHttpEffect( - HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), - { memoMap } - ) - const status = (handler: typeof publicHandler, path: string) => - handler.pipe( - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb(new Request("http://localhost" + path)) - ), - Effect.map((response) => response.status) - ) - assert.strictEqual(yield* status(publicHandler, "/public"), 200) - assert.strictEqual(yield* status(internalHandler, "/internal"), 200) - const publicMiss = yield* Effect.exit(status(publicHandler, "/internal")) - const internalMiss = yield* Effect.exit(status(internalHandler, "/public")) - assert.isTrue(Exit.isFailure(publicMiss)) - assert.isTrue(Exit.isFailure(internalMiss)) - if (Exit.isFailure(publicMiss)) assert.match(Cause.pretty(publicMiss.cause), /RouteNotFound/) - if (Exit.isFailure(internalMiss)) assert.match(Cause.pretty(internalMiss.cause), /RouteNotFound/) - }).pipe(Effect.scoped)) - - it("serves its own router when the app outputs another router in toWebHandler", async () => { - const foreign = Layer.effect( - HttpRouter.HttpRouter, - Effect.gen(function*() { - yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) - const router = yield* HttpRouter.make - yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) - return router - }) - ) - const { dispose, handler } = HttpRouter.toWebHandler(foreign, { disableLogger: true }) - try { - assert.strictEqual((await handler(new Request("http://localhost/own"))).status, 200) - assert.strictEqual((await handler(new Request("http://localhost/foreign"))).status, 404) - } finally { - await dispose() - } - }) - - it.effect("serves its own router when the app outputs another router in toHttpEffect", () => - Effect.gen(function*() { - const foreign = Layer.effect( - HttpRouter.HttpRouter, - Effect.gen(function*() { - yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) - const router = yield* HttpRouter.make - yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) - return router - }) - ) - const handler = yield* HttpRouter.toHttpEffect(foreign) - const status = (path: string) => - handler.pipe( - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb(new Request("http://localhost" + path)) - ), - Effect.map((response) => response.status) - ) - assert.strictEqual(yield* status("/own"), 200) - const foreignMiss = yield* Effect.exit(status("/foreign")) - assert.isTrue(Exit.isFailure(foreignMiss)) - if (Exit.isFailure(foreignMiss)) assert.match(Cause.pretty(foreignMiss.cause), /RouteNotFound/) - }).pipe(Effect.scoped)) - it("normalizes the prefix stored by prefixRoute", () => { const route = HttpRouter.prefixRoute( HttpRouter.route("GET", "/users", HttpServerResponse.text("ok")), diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index 9618b68d334..e9f7d701cd4 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -9,34 +9,9 @@ describe("HttpRouter", () => { expect>().type.toBe() }) - it("accepts app layers that output HttpRouter", () => { - const app = Layer.succeed(HttpRouter.HttpRouter, {} as HttpRouter.HttpRouter) - expect(HttpRouter.serve).type.toBeCallableWith(app) - expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) - expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) - }) - - it("accepts app layers with any output", () => { - const app = {} as Layer.Layer - expect(HttpRouter.serve).type.toBeCallableWith(app) - expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) - expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) - }) - - it("accepts app layers with unknown output", () => { - const app = {} as Layer.Layer - expect(HttpRouter.serve).type.toBeCallableWith(app) - expect(HttpRouter.toWebHandler).type.toBeCallableWith(app) - expect(HttpRouter.toHttpEffect).type.toBeCallableWith(app) - }) - - it("accepts unconstrained generic wrappers", () => { + it("accepts unconstrained app outputs", () => { const serve = (app: Layer.Layer) => HttpRouter.serve(app) - const toWebHandler = (app: Layer.Layer) => HttpRouter.toWebHandler(app) - const toHttpEffect = (app: Layer.Layer) => HttpRouter.toHttpEffect(app) expect(serve).type.toBeCallableWith(Layer.empty) - expect(toWebHandler).type.toBeCallableWith(Layer.empty) - expect(toHttpEffect).type.toBeCallableWith(Layer.empty) }) }) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 03977608039..4a4950f2501 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { NodeWS } from "@effect/platform-node/NodeSocket" import { assert, describe, expect, it } from "@effect/vitest" -import { ByteSize, Context, Effect, Option } from "effect" +import { ByteSize, Effect, Option } from "effect" import * as Duration from "effect/Duration" import * as Fiber from "effect/Fiber" import { constVoid } from "effect/Function" @@ -47,55 +47,20 @@ const IdParams = Schema.Struct({ const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { - const sharedHealthServers = (health: Layer.Layer) => + it.effect("registers a shared fresh route layer on both servers", () => Effect.gen(function*() { const publicServer = Http.createServer() const internalServer = Http.createServer() - const serve = (server: Http.Server, route: Layer.Layer) => - HttpRouter.serve(Layer.mergeAll(route, health), { disableListenLog: true, disableLogger: true }).pipe( + const health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy"))) + const serve = (server: Http.Server) => + HttpRouter.serve(health, { disableListenLog: true, disableLogger: true }).pipe( Layer.provide(NodeHttpServer.layer(() => server, { port: 0 })) ) - - yield* Layer.build(Layer.mergeAll( - serve(publicServer, HttpRouter.add("GET", "/public", HttpServerResponse.text("public"))), - serve(internalServer, HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal"))) - )) - - const status = (server: Http.Server, path: string) => - Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + path).then((response) => response.status)) - assert.strictEqual(yield* status(publicServer, "/public"), 200) - assert.strictEqual(yield* status(internalServer, "/internal"), 200) - assert.strictEqual(yield* status(publicServer, "/internal"), 404) - assert.strictEqual(yield* status(internalServer, "/public"), 404) - return { publicServer, internalServer, status } - }) - - it.effect("does not leak routes when servers share a route layer", () => - sharedHealthServers(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")))) - - it.effect("registers a shared fresh route layer on both servers", () => - Effect.gen(function*() { - const health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy"))) - const { internalServer, publicServer, status } = yield* sharedHealthServers(health) - assert.strictEqual(yield* status(publicServer, "/health"), 200) - assert.strictEqual(yield* status(internalServer, "/health"), 200) - })) - - it.effect("shares dependencies outside a fresh route layer", () => - Effect.gen(function*() { - class Counter extends Context.Service()("SharedHealthCounter") {} - let acquisitions = 0 - const counter = Layer.sync(Counter, () => ++acquisitions) - const healthRoute = Layer.effectDiscard(Effect.gen(function*() { - const value = yield* Counter - const router = yield* HttpRouter.HttpRouter - yield* router.add("GET", "/health", HttpServerResponse.text(String(value))) - })) - const health = Layer.fresh(healthRoute).pipe(Layer.provide(counter)) - const { internalServer, publicServer, status } = yield* sharedHealthServers(health) - assert.strictEqual(yield* status(publicServer, "/health"), 200) - assert.strictEqual(yield* status(internalServer, "/health"), 200) - assert.strictEqual(acquisitions, 1) + yield* Layer.build(Layer.mergeAll(serve(publicServer), serve(internalServer))) + for (const server of [publicServer, internalServer]) { + const response = yield* Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + "/health")) + assert.strictEqual(response.status, 200) + } })) it.effect("serves its own router when the app outputs another router", () => From ecaf94ea98e7b03c6f175413a9448e6189b144f7 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 07:47:38 +0000 Subject: [PATCH 19/35] test(http): assert serve omits router output --- packages/effect/typetest/http/HttpRouter.tst.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index e9f7d701cd4..654aaca531e 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -13,6 +13,18 @@ describe("HttpRouter", () => { const serve = (app: Layer.Layer) => HttpRouter.serve(app) expect(serve).type.toBeCallableWith(Layer.empty) }) + + it("omits the router output while preserving other services", () => { + class SomeService extends Context.Service()("SomeService") {} + + const app = Layer.merge( + Layer.effect(HttpRouter.HttpRouter, HttpRouter.make), + Layer.succeed(SomeService, { value: 1 }) + ) + const served = HttpRouter.serve(app) + + expect>().type.toBe() + }) }) describe("middleware", () => { From c7c566cacd8d76b3579c1f29bc59ec41a3f62430 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 07:49:47 +0000 Subject: [PATCH 20/35] fix(http): omit router from serve output type --- .changeset/isolate-http-serve-routers.md | 2 +- packages/effect/src/http/HttpRouter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 6524842f26b..6f1a90a4741 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -7,6 +7,6 @@ ### Breaking changes - `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. -- An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. +- An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. `serve` removes `HttpRouter` from its output while retaining other app outputs. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. - A route layer used by more than one entrypoint in the same layer graph, or with a shared `memoMap`, is registered only once and returns 404 on the others. Wrap it in `Layer.fresh`, for example `const Health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.empty()))`, and provide shared resources such as database pools outside `Layer.fresh` so they are still built once. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index d77fb0fa843..88977fed0ea 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -1328,7 +1328,7 @@ export const serve = | Request.On ) => Effect.Effect } ): Layer.Layer< - A, + Exclude, Request.Without, HttpServer.HttpServer | Exclude | Exclude, HttpRouter> > => { From a4e3b16fc346c39627d0a02d206d78af9d3c0615 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 08:24:01 +0000 Subject: [PATCH 21/35] test(http): keep focused router isolation coverage --- packages/effect/test/http/HttpRouter.test.ts | 22 -------- .../effect/typetest/http/HttpRouter.tst.ts | 10 ---- .../platform/node/test/NodeHttpServer.test.ts | 55 ++----------------- 3 files changed, 4 insertions(+), 83 deletions(-) diff --git a/packages/effect/test/http/HttpRouter.test.ts b/packages/effect/test/http/HttpRouter.test.ts index 6dc675df34b..39ffdd002c1 100644 --- a/packages/effect/test/http/HttpRouter.test.ts +++ b/packages/effect/test/http/HttpRouter.test.ts @@ -22,28 +22,6 @@ const fetchText = (app: Layer.Layer, path: ) describe("HttpRouter", () => { - it("isolates toWebHandler routes even with a shared memo map", async () => { - const memoMap = Layer.makeMemoMapUnsafe() - const publicHandler = HttpRouter.toWebHandler( - HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), - { memoMap, disableLogger: true } - ) - const internalHandler = HttpRouter.toWebHandler( - HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), - { memoMap, disableLogger: true } - ) - try { - const status = (handler: typeof publicHandler.handler, path: string) => - handler(new Request("http://localhost" + path)).then((response) => response.status) - assert.strictEqual(await status(publicHandler.handler, "/public"), 200) - assert.strictEqual(await status(internalHandler.handler, "/internal"), 200) - assert.strictEqual(await status(publicHandler.handler, "/internal"), 404) - assert.strictEqual(await status(internalHandler.handler, "/public"), 404) - } finally { - await Promise.all([publicHandler.dispose(), internalHandler.dispose()]) - } - }) - it("normalizes the prefix stored by prefixRoute", () => { const route = HttpRouter.prefixRoute( HttpRouter.route("GET", "/users", HttpServerResponse.text("ok")), diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index 654aaca531e..17f1795df57 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -4,16 +4,6 @@ import { describe, expect, it } from "tstyche" describe("HttpRouter", () => { describe("router ownership", () => { - it("exposes make but not the reusable router layer", () => { - expect().type.not.toHaveProperty("layer") - expect>().type.toBe() - }) - - it("accepts unconstrained app outputs", () => { - const serve = (app: Layer.Layer) => HttpRouter.serve(app) - expect(serve).type.toBeCallableWith(Layer.empty) - }) - it("omits the router output while preserving other services", () => { class SomeService extends Context.Service()("SomeService") {} diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 4a4950f2501..087fe878914 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -47,45 +47,6 @@ const IdParams = Schema.Struct({ const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { - it.effect("registers a shared fresh route layer on both servers", () => - Effect.gen(function*() { - const publicServer = Http.createServer() - const internalServer = Http.createServer() - const health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy"))) - const serve = (server: Http.Server) => - HttpRouter.serve(health, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provide(NodeHttpServer.layer(() => server, { port: 0 })) - ) - yield* Layer.build(Layer.mergeAll(serve(publicServer), serve(internalServer))) - for (const server of [publicServer, internalServer]) { - const response = yield* Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + "/health")) - assert.strictEqual(response.status, 200) - } - })) - - it.effect("serves its own router when the app outputs another router", () => - Effect.gen(function*() { - const server = Http.createServer() - const foreign = Layer.effect( - HttpRouter.HttpRouter, - Effect.gen(function*() { - yield* (yield* HttpRouter.HttpRouter).add("GET", "/own", HttpServerResponse.text("own")) - const router = yield* HttpRouter.make - yield* router.add("GET", "/foreign", HttpServerResponse.text("foreign")) - return router - }) - ) - yield* Layer.build( - HttpRouter.serve(foreign, { disableLogger: true, disableListenLog: true }).pipe( - Layer.provide(NodeHttpServer.layer(() => server, { port: 0 })) - ) - ) - const status = (path: string) => - Effect.promise(() => fetch("http://localhost:" + tcpPort(server) + path).then((response) => response.status)) - assert.strictEqual(yield* status("/own"), 200) - assert.strictEqual(yield* status("/foreign"), 404) - }).pipe(Effect.scoped)) - it.effect("keeps routes isolated between independent servers", () => Effect.gen(function*() { const publicServer = Http.createServer() @@ -102,21 +63,13 @@ describe("HttpServer", () => { ) ).pipe(Layer.build) - const publicAddress = publicServer.address() - const internalAddress = internalServer.address() - if ( - !publicAddress || typeof publicAddress === "string" || - !internalAddress || typeof internalAddress === "string" - ) { - throw new Error("Expected both servers to listen on TCP ports") - } const status = (port: number, path: string) => Effect.promise(() => fetch("http://localhost:" + port + path).then((response) => response.status)) - assert.strictEqual(yield* status(publicAddress.port, "/public"), 200) - assert.strictEqual(yield* status(internalAddress.port, "/internal"), 200) - assert.strictEqual(yield* status(publicAddress.port, "/internal"), 404) - assert.strictEqual(yield* status(internalAddress.port, "/public"), 404) + assert.strictEqual(yield* status(tcpPort(publicServer), "/public"), 200) + assert.strictEqual(yield* status(tcpPort(internalServer), "/internal"), 200) + assert.strictEqual(yield* status(tcpPort(publicServer), "/internal"), 404) + assert.strictEqual(yield* status(tcpPort(internalServer), "/public"), 404) })) it.effect("schema", () => From 1c0142f396983ac11c0b4ac9d61094df56e6d847 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 08:26:01 +0000 Subject: [PATCH 22/35] refactor(http): use fnUntraced when providing router --- packages/effect/src/http/HttpRouter.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 88977fed0ea..6b1392b9767 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -637,13 +637,11 @@ const provideRouter = ( appLayer: Layer.Layer, routerConfig: Partial | undefined ): Layer.Layer> => - Layer.fromBuild((memoMap, scope) => - Effect.gen(function*() { - const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) - const context = yield* Effect.provideService(Layer.buildWithMemoMap(appLayer, memoMap, scope), HttpRouter, router) - return Context.add(context, HttpRouter, router) - }) - ) + Layer.fromBuild(Effect.fnUntraced(function*(memoMap, scope) { + const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) + const context = yield* Effect.provideService(Layer.buildWithMemoMap(appLayer, memoMap, scope), HttpRouter, router) + return Context.add(context, HttpRouter, router) + })) const RouteTypeId = "~effect/http/HttpRouter/Route" From c8afcb93a410cbcb3b6f5378d3a2cf1c963389a2 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 09:29:22 +0000 Subject: [PATCH 23/35] test(http): cover reused route layer across servers --- packages/platform/node/test/NodeHttpServer.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 087fe878914..d018dfa98f3 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -51,8 +51,9 @@ describe("HttpServer", () => { Effect.gen(function*() { const publicServer = Http.createServer() const internalServer = Http.createServer() - const publicApp = HttpRouter.add("GET", "/public", HttpServerResponse.text("public")) - const internalApp = HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")) + const Health = HttpRouter.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( @@ -68,6 +69,8 @@ describe("HttpServer", () => { 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) })) From fb0b09c116c3d79cacd051d8387137ca543901cb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 09:33:06 +0000 Subject: [PATCH 24/35] fix(http): register route layers once per router --- .changeset/isolate-http-serve-routers.md | 1 - packages/effect/src/http/HttpRouter.ts | 54 ++++++++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 6f1a90a4741..ea01518174d 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -9,4 +9,3 @@ - `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. - An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. `serve` removes `HttpRouter` from its output while retaining other app outputs. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. -- A route layer used by more than one entrypoint in the same layer graph, or with a shared `memoMap`, is registered only once and returns 404 on the others. Wrap it in `Layer.fresh`, for example `const Health = Layer.fresh(HttpRouter.add("GET", "/health", HttpServerResponse.empty()))`, and provide shared resources such as database pools outside `Layer.fresh` so they are still built once. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 6b1392b9767..676f83bfcb9 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -12,6 +12,7 @@ */ import * as Arr from "../Array.ts" import * as Context from "../Context.ts" +import * as Deferred from "../Deferred.ts" import * as Effect from "../Effect.ts" import { compose, dual, identity } from "../Function.ts" import { fiberEnterInterruptibleUnsafe } from "../internal/effect.ts" @@ -504,7 +505,34 @@ export const schemaPathParams = ( f: (router: HttpRouter) => Effect.Effect -): Layer.Layer> => Layer.effectDiscard(Effect.flatMap(HttpRouter, f)) +): Layer.Layer> => { + const self = Layer.fromBuild((_, scope) => + Effect.flatMap(HttpRouter, (router) => + Effect.suspend(() => { + let registrations = registeredLayers.get(router) + if (!registrations) registeredLayers.set(router, registrations = new WeakMap()) + let deferred: Deferred.Deferred | undefined + return Effect.onExitPrimitive( + Effect.suspend(() => { + const existing = registrations.get(self) + if (existing) return Deferred.await(existing) + deferred = Deferred.makeUnsafe() + registrations.set(self, deferred) + return Effect.asVoid(Scope.provide(f(router), scope)) + }), + (exit) => { + if (deferred) { + if (exit._tag === "Failure") registrations.delete(self) + Deferred.doneUnsafe(deferred, exit) + } + } + ).pipe(Effect.as(Context.empty())) + })) + ) + return self +} + +const registeredLayers = new WeakMap>>() /** * Create a layer that adds a single route to the HTTP router. @@ -576,9 +604,8 @@ export const addAll = >, EX = never | Request.From<"Requires", Exclude, Provided>> | Request.From<"Error", Route.Error> > => - Layer.effectDiscard(Effect.gen(function*() { + use(Effect.fnUntraced(function*(router) { const toAdd = Effect.isEffect(routes) ? yield* routes : routes - let router = yield* HttpRouter if (options?.prefix) { router = router.prefixed(options.prefix) } @@ -595,12 +622,6 @@ export const addAll = >, EX = never * `Scope`; route request markers are converted into the ordinary requirements of * the returned handler. Each call creates its own router. * - * **Gotchas** - * - * A route layer shared with another entrypoint in the same layer graph, or - * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, - * and provide shared resources outside `Layer.fresh` so they are built once. - * * @stability unstable * @category converting * @since 4.0.0 @@ -1005,8 +1026,7 @@ const makeMiddleware = (middleware: any, options?: { readonly global?: boolean | undefined }) => options?.global ? - Layer.effectDiscard(Effect.gen(function*() { - const router = yield* HttpRouter + use(Effect.fnUntraced(function*(router) { const fn = Effect.isEffect(middleware) ? yield* middleware : middleware yield* router.addGlobalMiddleware(fn) })) @@ -1288,12 +1308,6 @@ export const provideRequest = /** * Runs the provided application layer as an HTTP server with its own router. * - * **Gotchas** - * - * A route layer shared with another entrypoint in the same layer graph, or - * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, - * and provide shared resources outside `Layer.fresh` so they are built once. - * * @stability unstable * @category layers * @since 4.0.0 @@ -1369,12 +1383,6 @@ export const serve = | Request.On * If the build fails, every request rejects with the build error. Each call * creates its own router. * - * **Gotchas** - * - * A route layer shared with another entrypoint in the same layer graph, or - * through a shared `memoMap`, is registered only once. Wrap it in `Layer.fresh`, - * and provide shared resources outside `Layer.fresh` so they are built once. - * * @stability unstable * @category converting * @since 4.0.0 From a712baac6489e1ddecb71239739f883c086d4edb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 09:42:09 +0000 Subject: [PATCH 25/35] style: format isolated server route test --- packages/platform/node/test/NodeHttpServer.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index d018dfa98f3..19a075c268f 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -53,7 +53,10 @@ describe("HttpServer", () => { const internalServer = Http.createServer() const Health = HttpRouter.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) + const internalApp = Layer.mergeAll( + HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), + Health + ) yield* Layer.mergeAll( HttpRouter.serve(publicApp, { disableListenLog: true, disableLogger: true }).pipe( From ce5eddb2f50daa4239083a22e3274703919d6721 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 09:45:52 +0000 Subject: [PATCH 26/35] docs(http): clarify memoized route registration caveat --- .changeset/isolate-http-serve-routers.md | 2 ++ packages/effect/src/http/HttpRouter.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index ea01518174d..551fba63a8e 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -4,6 +4,8 @@ `HttpRouter.serve`, `toWebHandler`, and `toHttpEffect` now each create their own router, so servers and handlers in one layer graph, or sharing a `memoMap`, no longer share routes. `toHttpEffect` also accepts a `memoMap` option. +Reused `HttpRouter.add`, `addAll`, and `use` layers register on each router. A memoized wrapper around them, or a custom layer that registers routes, may build only once across entrypoints; use `Layer.fresh` on that layer if each router needs its routes. + ### Breaking changes - `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 676f83bfcb9..1cf61dcba14 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -475,6 +475,10 @@ export const schemaPathParams = >, EX = never * `Scope`; route request markers are converted into the ordinary requirements of * the returned handler. Each call creates its own router. * + * A reused memoized wrapper around route layers, or a custom registration + * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer + * when each router needs its routes. + * * @stability unstable * @category converting * @since 4.0.0 @@ -1308,6 +1316,12 @@ export const provideRequest = /** * Runs the provided application layer as an HTTP server with its own router. * + * **Details** + * + * A reused memoized wrapper around route layers, or a custom registration + * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer + * when each router needs its routes. + * * @stability unstable * @category layers * @since 4.0.0 @@ -1383,6 +1397,10 @@ export const serve = | Request.On * If the build fails, every request rejects with the build error. Each call * creates its own router. * + * A reused memoized wrapper around route layers, or a custom registration + * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer + * when each router needs its routes. + * * @stability unstable * @category converting * @since 4.0.0 From ed0af5a4a93db3a1b72cafe5ac50787d3aad1665 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 10:34:56 +0000 Subject: [PATCH 27/35] test(http): cover direct router registration across servers --- packages/platform/node/test/NodeHttpServer.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 19a075c268f..eeed917ca38 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -51,7 +51,10 @@ describe("HttpServer", () => { Effect.gen(function*() { const publicServer = Http.createServer() const internalServer = Http.createServer() - const Health = HttpRouter.add("GET", "/health", HttpServerResponse.text("healthy")) + 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")), From b805a131a7eaa33fd311aa39edad4f71e876caff Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 10:38:24 +0000 Subject: [PATCH 28/35] fix(http): fork app memo map per router entrypoint --- .changeset/isolate-http-serve-routers.md | 2 +- packages/effect/src/http/HttpRouter.ts | 55 ++++++------------------ 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 551fba63a8e..04af05fea8e 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -4,7 +4,7 @@ `HttpRouter.serve`, `toWebHandler`, and `toHttpEffect` now each create their own router, so servers and handlers in one layer graph, or sharing a `memoMap`, no longer share routes. `toHttpEffect` also accepts a `memoMap` option. -Reused `HttpRouter.add`, `addAll`, and `use` layers register on each router. A memoized wrapper around them, or a custom layer that registers routes, may build only once across entrypoints; use `Layer.fresh` on that layer if each router needs its routes. +Each entrypoint builds its app layers privately, so even reused or custom route layers register on each router. Dependencies built inside an app are also private to that entrypoint. Provide services outside the app to share them; a supplied `memoMap` still reuses dependencies already built in it. ### Breaking changes diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 1cf61dcba14..0084105197a 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -12,7 +12,6 @@ */ import * as Arr from "../Array.ts" import * as Context from "../Context.ts" -import * as Deferred from "../Deferred.ts" import * as Effect from "../Effect.ts" import { compose, dual, identity } from "../Function.ts" import { fiberEnterInterruptibleUnsafe } from "../internal/effect.ts" @@ -475,10 +474,6 @@ export const schemaPathParams = ( f: (router: HttpRouter) => Effect.Effect -): Layer.Layer> => { - const self = Layer.fromBuild((_, scope) => - Effect.flatMap(HttpRouter, (router) => - Effect.suspend(() => { - let registrations = registeredLayers.get(router) - if (!registrations) registeredLayers.set(router, registrations = new WeakMap()) - let deferred: Deferred.Deferred | undefined - return Effect.onExitPrimitive( - Effect.suspend(() => { - const existing = registrations.get(self) - if (existing) return Deferred.await(existing) - deferred = Deferred.makeUnsafe() - registrations.set(self, deferred) - return Effect.asVoid(Scope.provide(f(router), scope)) - }), - (exit) => { - if (deferred) { - if (exit._tag === "Failure") registrations.delete(self) - Deferred.doneUnsafe(deferred, exit) - } - } - ).pipe(Effect.as(Context.empty())) - })) - ) - return self -} - -const registeredLayers = new WeakMap>>() +): Layer.Layer> => Layer.effectDiscard(Effect.flatMap(HttpRouter, f)) /** * Create a layer that adds a single route to the HTTP router. @@ -626,9 +594,8 @@ export const addAll = >, EX = never * `Scope`; route request markers are converted into the ordinary requirements of * the returned handler. Each call creates its own router. * - * A reused memoized wrapper around route layers, or a custom registration - * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer - * when each router needs its routes. + * Layers built inside the app are private to this entrypoint. Layers already + * built in the supplied `memoMap` are reused. * * @stability unstable * @category converting @@ -668,7 +635,11 @@ const provideRouter = ( ): Layer.Layer> => Layer.fromBuild(Effect.fnUntraced(function*(memoMap, scope) { const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) - const context = yield* Effect.provideService(Layer.buildWithMemoMap(appLayer, memoMap, scope), HttpRouter, router) + const context = yield* Effect.provideService( + Layer.buildWithMemoMap(appLayer, Layer.forkMemoMapUnsafe(memoMap), scope), + HttpRouter, + router + ) return Context.add(context, HttpRouter, router) })) @@ -1318,9 +1289,8 @@ export const provideRequest = * * **Details** * - * A reused memoized wrapper around route layers, or a custom registration - * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer - * when each router needs its routes. + * Layers built inside the app are private to this server. Provide shared + * services outside the app. * * @stability unstable * @category layers @@ -1397,9 +1367,8 @@ export const serve = | Request.On * If the build fails, every request rejects with the build error. Each call * creates its own router. * - * A reused memoized wrapper around route layers, or a custom registration - * layer, may build only once across entrypoints. Use `Layer.fresh` on that layer - * when each router needs its routes. + * Layers built inside the app are private to this handler. Layers already + * built in the supplied `memoMap` are reused. * * @stability unstable * @category converting From fe563a902bf214cd69e08ba2dc2ffec057c0407f Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 10:40:01 +0000 Subject: [PATCH 29/35] style(test): format server isolation regression --- packages/platform/node/test/NodeHttpServer.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index eeed917ca38..307a8d588fb 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -63,7 +63,8 @@ describe("HttpServer", () => { yield* Layer.mergeAll( HttpRouter.serve(publicApp, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provide(NodeHttpServer.layer(() => publicServer, { port: 0 })) + Layer.provide(NodeHttpServer.layer(() => + publicServer, { port: 0 })) ), HttpRouter.serve(internalApp, { disableListenLog: true, disableLogger: true }).pipe( Layer.provide(NodeHttpServer.layer(() => internalServer, { port: 0 })) From c1a6082f3b02a20410100ed33d5a54953ddde7a4 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 10:44:17 +0000 Subject: [PATCH 30/35] docs(http): clarify entrypoint dependency sharing --- .changeset/isolate-http-serve-routers.md | 3 ++- packages/effect/src/http/HttpRouter.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 04af05fea8e..7249eaf8392 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -4,10 +4,11 @@ `HttpRouter.serve`, `toWebHandler`, and `toHttpEffect` now each create their own router, so servers and handlers in one layer graph, or sharing a `memoMap`, no longer share routes. `toHttpEffect` also accepts a `memoMap` option. -Each entrypoint builds its app layers privately, so even reused or custom route layers register on each router. Dependencies built inside an app are also private to that entrypoint. Provide services outside the app to share them; a supplied `memoMap` still reuses dependencies already built in it. +Each entrypoint builds its app layers privately, so even reused or custom route layers register on each router. ### Breaking changes +- Layers first built inside an entrypoint app are private to it; layers already built in the parent memo map are reused. A service provided both inside the app and to a sibling may therefore be built twice, depending on build order. Provide services that must be shared outside the entrypoint. - `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. - An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. `serve` removes `HttpRouter` from its output while retaining other app outputs. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index 0084105197a..eb0275c04f5 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -1289,8 +1289,10 @@ export const provideRequest = * * **Details** * - * Layers built inside the app are private to this server. Provide shared - * services outside the app. + * Layers first built inside the app are private to this server; layers already + * built outside it are reused. A service provided both inside the app and to a + * sibling may be built twice depending on build order. Provide services that + * must be shared outside the server. * * @stability unstable * @category layers From 69861fb2d807c4e05ce79aaa586f02ff76348484 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 21:15:00 +0000 Subject: [PATCH 31/35] Restore public HttpRouter.layer with isolated entrypoints --- .changeset/isolate-http-serve-routers.md | 1 - packages/effect/src/http/HttpRouter.ts | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index 7249eaf8392..d8eaa1c30a3 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -9,6 +9,5 @@ Each entrypoint builds its app layers privately, so even reused or custom route ### Breaking changes - Layers first built inside an entrypoint app are private to it; layers already built in the parent memo map are reused. A service provided both inside the app and to a sibling may therefore be built twice, depending on build order. Provide services that must be shared outside the entrypoint. -- `HttpRouter.layer` was removed. Delete the router layer and pass route layers to an entrypoint: `HttpRouter.toWebHandler(routes)` instead of `HttpRouter.toWebHandler(routes.pipe(Layer.provideMerge(HttpRouter.layer)))`. - An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. `serve` removes `HttpRouter` from its output while retaining other app outputs. - Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index eb0275c04f5..e3b7f5a6c3c 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -584,6 +584,18 @@ export const addAll = >, EX = never yield* router.addAll(toAdd) })) +/** + * Layer that provides a newly constructed `HttpRouter`. + * + * This layer is memoized within its graph. Router entrypoints create their own + * router even when this layer is provided outside the app. + * + * @stability unstable + * @category layers + * @since 4.0.0 + */ +export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) + /** * Builds an application layer with a router and returns the router as an HTTP * handler effect. From 7c6d700b2523119319e0708a194b53b7b811f795 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 21:21:34 +0000 Subject: [PATCH 32/35] Simplify router isolation with forked layer memo maps --- .changeset/isolate-http-serve-routers.md | 10 +- .../effect__platform__HttpLayerRouter.yaml | 4 +- .../effect__platform__HttpRouter.yaml | 4 +- migration/v3-to-v4.md | 4 +- packages/effect/src/http/HttpRouter.ts | 108 ++++++------------ 5 files changed, 43 insertions(+), 87 deletions(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index d8eaa1c30a3..c3be6b8e527 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,12 +2,4 @@ "effect": patch --- -`HttpRouter.serve`, `toWebHandler`, and `toHttpEffect` now each create their own router, so servers and handlers in one layer graph, or sharing a `memoMap`, no longer share routes. `toHttpEffect` also accepts a `memoMap` option. - -Each entrypoint builds its app layers privately, so even reused or custom route layers register on each router. - -### Breaking changes - -- Layers first built inside an entrypoint app are private to it; layers already built in the parent memo map are reused. A service provided both inside the app and to a sibling may therefore be built twice, depending on build order. Provide services that must be shared outside the entrypoint. -- An app layer may output a different `HttpRouter`, but the entrypoint serves its own router, not the one in the app output. `serve` removes `HttpRouter` from its output while retaining other app outputs. -- Routes registered on a router provided outside `serve` are no longer served. Move the route layer into the app: `HttpRouter.serve(YourRpcHandlers.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`. +Build each HttpRouter entrypoint in a forked layer memo map with a fresh router. This keeps routes on separate servers and handlers isolated while retaining already-built parent services. diff --git a/migration/annotations/effect__platform__HttpLayerRouter.yaml b/migration/annotations/effect__platform__HttpLayerRouter.yaml index 150910fae31..c77bd7663f2 100644 --- a/migration/annotations/effect__platform__HttpLayerRouter.yaml +++ b/migration/annotations/effect__platform__HttpLayerRouter.yaml @@ -62,8 +62,8 @@ replacement: "HttpRouter.addAll" note: "Moved to the consolidated HttpRouter; it still registers route values through a Layer and supports a prefix option." "@effect/platform/HttpLayerRouter#layer": - replacement: "none" - note: "HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each provide their own router; remove the router layer and pass route layers to one of them." + replacement: "HttpRouter.layer" + note: "Use the layer for the consolidated HttpRouter service." "@effect/platform/HttpLayerRouter#toHttpEffect": replacement: "HttpRouter.toHttpEffect" note: "Moved to the consolidated HttpRouter; route-not-found failures now use HttpServerError.HttpServerError." diff --git a/migration/annotations/effect__platform__HttpRouter.yaml b/migration/annotations/effect__platform__HttpRouter.yaml index 424ab4b75a8..7fdd24d99c2 100644 --- a/migration/annotations/effect__platform__HttpRouter.yaml +++ b/migration/annotations/effect__platform__HttpRouter.yaml @@ -23,8 +23,8 @@ replacement: "HttpRouter.RouterConfig" note: "The FiberRef became a Context.Reference containing Partial." "@effect/platform/HttpRouter#Default": - replacement: "HttpRouter.HttpRouter" - note: "Custom/default router tags were removed; route layers require HttpRouter.HttpRouter, which HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect provide." + replacement: "HttpRouter.HttpRouter + HttpRouter.layer" + note: "Custom/default router tags were removed; v4 provides one router service." "@effect/platform/HttpRouter#empty": replacement: "Layer.empty" note: "There is no immutable empty router; use an empty registration layer." diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index 647aabfc1d7..fb13934f5e8 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -7133,7 +7133,7 @@ effect/rpc/Utils (barrel: effect/rpc) - `HttpLayerRouter.cors` -> `HttpRouter.cors`: HttpLayerRouter was consolidated into v4 HttpRouter. -- `HttpLayerRouter.layer` -> `none`: HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect each provide their own router; remove the router layer and pass route layers to one of them. +- `HttpLayerRouter.layer` -> `HttpRouter.layer`: Use the layer for the consolidated HttpRouter service. - `HttpLayerRouter.make` -> `HttpRouter.make`: The layer-oriented router became the sole v4 HttpRouter implementation. @@ -7177,7 +7177,7 @@ effect/rpc/Utils (barrel: effect/rpc) ### `@effect/platform/HttpRouter` -- `HttpRouter.Default` -> `HttpRouter.HttpRouter`: Custom/default router tags were removed; route layers require HttpRouter.HttpRouter, which HttpRouter.serve, HttpRouter.toWebHandler, and HttpRouter.toHttpEffect provide. +- `HttpRouter.Default` -> `HttpRouter.HttpRouter + HttpRouter.layer`: Custom/default router tags were removed; v4 provides one router service. - `HttpRouter.HttpRouter` -> `HttpRouter.HttpRouter`: The name remains, but now denotes the mutable layer-oriented registration service. diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index e3b7f5a6c3c..c3555631f30 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -117,12 +117,6 @@ export const HttpRouter: Context.Service = Context.Servi * The returned router accepts route and middleware registrations and later routes * the current `HttpServerRequest` to the matching `HttpServerResponse`. * - * **Gotchas** - * - * `serve`, `toWebHandler`, and `toHttpEffect` create their own router. Use - * `make` for custom integrations. A router output by an app layer does not - * replace the router served by an entrypoint. - * * @stability unstable * @category constructors * @since 4.0.0 @@ -576,8 +570,9 @@ export const addAll = >, EX = never | Request.From<"Requires", Exclude, Provided>> | Request.From<"Error", Route.Error> > => - use(Effect.fnUntraced(function*(router) { + Layer.effectDiscard(Effect.gen(function*() { const toAdd = Effect.isEffect(routes) ? yield* routes : routes + let router = yield* HttpRouter if (options?.prefix) { router = router.prefixed(options.prefix) } @@ -587,15 +582,21 @@ export const addAll = >, EX = never /** * Layer that provides a newly constructed `HttpRouter`. * - * This layer is memoized within its graph. Router entrypoints create their own - * router even when this layer is provided outside the app. - * * @stability unstable * @category layers * @since 4.0.0 */ export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) +const isolate = (app: Layer.Layer): Layer.Layer => + Layer.fromBuild((memoMap, scope) => + Effect.flatMap(Effect.context(), (context) => + Effect.provideContext( + Layer.buildWithMemoMap(app, Layer.forkMemoMapUnsafe(memoMap), scope), + Context.omit(HttpRouter)(context) + )) + ) + /** * Builds an application layer with a router and returns the router as an HTTP * handler effect. @@ -604,20 +605,14 @@ export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) * * The returned effect handles the current `HttpServerRequest` in the current * `Scope`; route request markers are converted into the ordinary requirements of - * the returned handler. Each call creates its own router. - * - * Layers built inside the app are private to this entrypoint. Layers already - * built in the supplied `memoMap` are reused. + * the returned handler. * * @stability unstable * @category converting * @since 4.0.0 */ export const toHttpEffect = ( - appLayer: Layer.Layer, - options?: { - readonly memoMap?: Layer.MemoMap | undefined - } + appLayer: Layer.Layer ): Effect.Effect< Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -628,33 +623,12 @@ export const toHttpEffect = ( Exclude, HttpRouter> | Scope.Scope > => Effect.gen(function*() { - const app = provideRouter(appLayer, undefined) - const context = yield* (options?.memoMap - ? Layer.buildWithMemoMap(app, options.memoMap, yield* Effect.scope) - : Layer.build(app)) + const context = yield* Layer.build(isolate(Layer.provideMerge(appLayer, Layer.fresh(layer)))) const router = Context.get(context, HttpRouter) // @effect-diagnostics effect/returnEffectInGen:off return router.asHttpEffect() }) as any -/** - * Builds the app layer with a new router, so apps never share a router through - * layer memoization. - */ -const provideRouter = ( - appLayer: Layer.Layer, - routerConfig: Partial | undefined -): Layer.Layer> => - Layer.fromBuild(Effect.fnUntraced(function*(memoMap, scope) { - const router = yield* (routerConfig ? Effect.provideService(make, RouterConfig, routerConfig) : make) - const context = yield* Effect.provideService( - Layer.buildWithMemoMap(appLayer, Layer.forkMemoMapUnsafe(memoMap), scope), - HttpRouter, - router - ) - return Context.add(context, HttpRouter, router) - })) - const RouteTypeId = "~effect/http/HttpRouter/Route" /** @@ -1017,7 +991,8 @@ const makeMiddleware = (middleware: any, options?: { readonly global?: boolean | undefined }) => options?.global ? - use(Effect.fnUntraced(function*(router) { + Layer.effectDiscard(Effect.gen(function*() { + const router = yield* HttpRouter const fn = Effect.isEffect(middleware) ? yield* middleware : middleware yield* router.addGlobalMiddleware(fn) })) @@ -1297,14 +1272,7 @@ export const provideRequest = ) /** - * Runs the provided application layer as an HTTP server with its own router. - * - * **Details** - * - * Layers first built inside the app are private to this server; layers already - * built outside it are reused. A service provided both inside the app and to a - * sibling may be built twice depending on build order. Provide services that - * must be shared outside the server. + * Runs the provided application layer as an HTTP server. * * @stability unstable * @category layers @@ -1338,7 +1306,7 @@ export const serve = | Request.On ) => Effect.Effect } ): Layer.Layer< - Exclude, + A, Request.Without, HttpServer.HttpServer | Exclude | Exclude, HttpRouter> > => { @@ -1346,23 +1314,20 @@ export const serve = | Request.On if (options?.disableLogger !== true) { middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger } - const app = provideRouter(appLayer, options?.routerConfig) - return Layer.fromBuild((memoMap, scope) => + const RouterLayer = options?.routerConfig + ? Layer.provide(Layer.fresh(layer), Layer.succeed(RouterConfig)(options.routerConfig)) + : Layer.fresh(layer) + return isolate( Effect.gen(function*() { - const context = yield* Layer.buildWithMemoMap(app, memoMap, scope) - const handler = Context.get(context, HttpRouter).asHttpEffect() - yield* Effect.provideContext( - Layer.buildWithMemoMap( - middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler), - memoMap, - scope - ), - context - ) - return Context.omit(HttpRouter)(context) - }) - ).pipe( - options?.disableListenLog ? identity : HttpServer.withLogAddress + const router = yield* HttpRouter + const handler = router.asHttpEffect() + return middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler) + }).pipe( + Layer.unwrap, + Layer.provideMerge(appLayer), + Layer.provide(RouterLayer), + options?.disableListenLog ? identity : HttpServer.withLogAddress + ) ) as any } @@ -1378,11 +1343,7 @@ export const serve = | Request.On * handler is created rather than on the first request. A layer that performs * asynchronous work while building may still be in progress when the first * request arrives, in which case that request waits for the build to finish. - * If the build fails, every request rejects with the build error. Each call - * creates its own router. - * - * Layers built inside the app are private to this handler. Layers already - * built in the supplied `memoMap` are reused. + * If the build fails, every request rejects with the build error. * * @stability unstable * @category converting @@ -1440,8 +1401,11 @@ export const toWebHandler = < if (options?.disableLogger !== true) { middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger } + const RouterLayer = options?.routerConfig + ? Layer.provide(Layer.fresh(layer), Layer.succeed(RouterConfig)(options.routerConfig)) + : Layer.fresh(layer) return HttpEffect.toWebHandlerLayerWith( - provideRouter(appLayer, options?.routerConfig) as Layer.Layer, + isolate(Layer.provideMerge(appLayer, RouterLayer)) as Layer.Layer, { toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()), middleware, From 3602033b3cde4fe97804de7106b7bd67592532fb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 21:24:38 +0000 Subject: [PATCH 33/35] Trim router isolation tests and restore MCP fixture --- .../effect/test/ai/McpServer/McpServer.test.ts | 4 +++- packages/effect/typetest/http/HttpRouter.tst.ts | 14 -------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 6c7d9c933e6..2087a7ef963 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -2046,6 +2046,7 @@ describe("McpServer", () => { }).pipe(Effect.forkScoped) yield* router.add("POST", "/mcp", () => httpEffect) })).pipe( + Layer.provideMerge(HttpRouter.layer), Layer.provide(RpcSerialization.layerJsonRpc()) ) const harness = yield* makeHttpHarness(serverLayer) @@ -2700,7 +2701,8 @@ describe("McpServer", () => { version: "1.0.0", protocols: [McpProtocol.v2026_07_28] }).pipe( - Layer.provide(stdioLayer) + Layer.provide(stdioLayer), + Layer.provideMerge(HttpRouter.layer) ) ) yield* Deferred.succeed(serverReady, Context.get(context, McpServer.McpServer)) diff --git a/packages/effect/typetest/http/HttpRouter.tst.ts b/packages/effect/typetest/http/HttpRouter.tst.ts index 17f1795df57..5fc46255b33 100644 --- a/packages/effect/typetest/http/HttpRouter.tst.ts +++ b/packages/effect/typetest/http/HttpRouter.tst.ts @@ -3,20 +3,6 @@ import { HttpRouter, type HttpServerError, HttpServerResponse } from "effect/htt import { describe, expect, it } from "tstyche" describe("HttpRouter", () => { - describe("router ownership", () => { - it("omits the router output while preserving other services", () => { - class SomeService extends Context.Service()("SomeService") {} - - const app = Layer.merge( - Layer.effect(HttpRouter.HttpRouter, HttpRouter.make), - Layer.succeed(SomeService, { value: 1 }) - ) - const served = HttpRouter.serve(app) - - expect>().type.toBe() - }) - }) - describe("middleware", () => { it("provides handled request errors", () => { class MyError { From 822af092b4265c89f92515adbdf15be3bd6ff1db Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 24 Sep 2026 21:32:55 +0000 Subject: [PATCH 34/35] docs(http): clarify router isolation migration --- .changeset/isolate-http-serve-routers.md | 7 ++++++- packages/effect/src/http/HttpRouter.ts | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/isolate-http-serve-routers.md b/.changeset/isolate-http-serve-routers.md index c3be6b8e527..b4e020f37e8 100644 --- a/.changeset/isolate-http-serve-routers.md +++ b/.changeset/isolate-http-serve-routers.md @@ -2,4 +2,9 @@ "effect": patch --- -Build each HttpRouter entrypoint in a forked layer memo map with a fresh router. This keeps routes on separate servers and handlers isolated while retaining already-built parent services. +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 c3555631f30..ccea9717eab 100644 --- a/packages/effect/src/http/HttpRouter.ts +++ b/packages/effect/src/http/HttpRouter.ts @@ -1274,6 +1274,11 @@ 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`. + * * @stability unstable * @category layers * @since 4.0.0 From 2b37f5feed0cedc8ac2df479baf9354830f0e90e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Fri, 25 Sep 2026 10:21:36 +1200 Subject: [PATCH 35/35] wip --- packages/effect/src/http/HttpRouter.ts | 70 +++++++++++-------- .../platform/node/test/NodeHttpServer.test.ts | 6 +- packages/platform/node/test/RpcServer.test.ts | 2 + 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/effect/src/http/HttpRouter.ts b/packages/effect/src/http/HttpRouter.ts index ccea9717eab..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, @@ -588,15 +589,6 @@ export const addAll = >, EX = never */ export const layer: Layer.Layer = Layer.effect(HttpRouter)(make) -const isolate = (app: Layer.Layer): Layer.Layer => - Layer.fromBuild((memoMap, scope) => - Effect.flatMap(Effect.context(), (context) => - Effect.provideContext( - Layer.buildWithMemoMap(app, Layer.forkMemoMapUnsafe(memoMap), scope), - Context.omit(HttpRouter)(context) - )) - ) - /** * Builds an application layer with a router and returns the router as an HTTP * handler effect. @@ -607,6 +599,11 @@ const isolate = (app: Layer.Layer): Layer.Layer => * `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 @@ -622,11 +619,13 @@ export const toHttpEffect = ( Request.Without, Exclude, HttpRouter> | Scope.Scope > => - Effect.gen(function*() { - const context = yield* Layer.build(isolate(Layer.provideMerge(appLayer, Layer.fresh(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" @@ -1279,6 +1278,11 @@ export const provideRequest = * 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 @@ -1319,20 +1323,26 @@ export const serve = | Request.On if (options?.disableLogger !== true) { middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger } - const RouterLayer = options?.routerConfig - ? Layer.provide(Layer.fresh(layer), Layer.succeed(RouterConfig)(options.routerConfig)) - : Layer.fresh(layer) - return isolate( - Effect.gen(function*() { - const router = yield* HttpRouter + 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 middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler) - }).pipe( - Layer.unwrap, - Layer.provideMerge(appLayer), - Layer.provide(RouterLayer), - options?.disableListenLog ? identity : HttpServer.withLogAddress - ) + return Layer.merge( + middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler), + Layer.succeedContext(context) + ) + }), + Layer.unwrap, + options?.disableListenLog ? identity : HttpServer.withLogAddress ) as any } @@ -1407,10 +1417,10 @@ export const toWebHandler = < middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger } const RouterLayer = options?.routerConfig - ? Layer.provide(Layer.fresh(layer), Layer.succeed(RouterConfig)(options.routerConfig)) - : Layer.fresh(layer) + ? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig)) + : layer return HttpEffect.toWebHandlerLayerWith( - isolate(Layer.provideMerge(appLayer, RouterLayer)) as Layer.Layer, + Layer.provideMerge(appLayer, RouterLayer) as Layer.Layer, { toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()), middleware, diff --git a/packages/platform/node/test/NodeHttpServer.test.ts b/packages/platform/node/test/NodeHttpServer.test.ts index 307a8d588fb..f2953137fe3 100644 --- a/packages/platform/node/test/NodeHttpServer.test.ts +++ b/packages/platform/node/test/NodeHttpServer.test.ts @@ -51,11 +51,15 @@ describe("HttpServer", () => { 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 publicApp = Layer.mergeAll( + HttpRouter.add("GET", "/public", HttpServerResponse.text("public")), + Health + ) const internalApp = Layer.mergeAll( HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")), Health diff --git a/packages/platform/node/test/RpcServer.test.ts b/packages/platform/node/test/RpcServer.test.ts index 7a7589ff154..7cdbf00e882 100644 --- a/packages/platform/node/test/RpcServer.test.ts +++ b/packages/platform/node/test/RpcServer.test.ts @@ -10,6 +10,7 @@ import { e2eSuite, UsersClient } from "./fixtures/rpc-e2e.ts" import { RpcLayer, User } from "./fixtures/rpc-schemas.ts" describe("RpcServer", () => { + // http ndjson const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }) const HttpNdjsonServer = HttpRouter.serve( RpcLayer.pipe(Layer.provideMerge(HttpProtocol)), @@ -49,6 +50,7 @@ describe("RpcServer", () => { ) ) + // websocket const WsProtocol = RpcServer.layerProtocolWebsocket({ path: "/rpc" }) const HttpWsServer = HttpRouter.serve( RpcLayer.pipe(Layer.provideMerge(WsProtocol)),