Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f85a075
test: reproduce shared routes across independent HTTP servers
tim-smart Sep 24, 2026
50c2f80
fix(http): isolate routers for independent servers
tim-smart Sep 24, 2026
0d749d7
test(rpc): register HTTP protocols within served router
tim-smart Sep 24, 2026
8870e92
docs(changeset): note isolated HttpRouter.serve migration
tim-smart Sep 24, 2026
eb2c825
docs(changeset): remove stray marker
tim-smart Sep 24, 2026
6d24dcc
docs(changeset): qualify serve isolation and clarify migration
tim-smart Sep 24, 2026
6b80850
Tighten router isolation release note and remove redundant comments
tim-smart Sep 24, 2026
0633186
test(http): specify router ownership across entrypoints
tim-smart Sep 24, 2026
6034d82
feat(http): make HttpRouter entrypoints own their router
tim-smart Sep 24, 2026
2aae4ee
docs(migration): point router layer users at HttpRouter entrypoints
tim-smart Sep 24, 2026
49ade9c
test(http): repair router ownership type contracts
tim-smart Sep 24, 2026
54f614e
test(http): pin shared route layer isolation and fresh workaround
tim-smart Sep 24, 2026
a57855e
fix(http): exempt any app layers and document shared route layers
tim-smart Sep 24, 2026
c0f4729
test(http): require unrestricted router app outputs and public make
tim-smart Sep 24, 2026
cc81566
fix(http): restore HttpRouter.make and drop app output type constraint
tim-smart Sep 24, 2026
5657c33
test(http): accept app router outputs without serving foreign routes
tim-smart Sep 24, 2026
b51d57e
fix(http): accept app router outputs without replacing owned router
tim-smart Sep 24, 2026
97d92e5
test(http): focus router isolation coverage
tim-smart Sep 24, 2026
ecaf94e
test(http): assert serve omits router output
tim-smart Sep 24, 2026
c7c566c
fix(http): omit router from serve output type
tim-smart Sep 24, 2026
a4e3b16
test(http): keep focused router isolation coverage
tim-smart Sep 24, 2026
1c0142f
refactor(http): use fnUntraced when providing router
tim-smart Sep 24, 2026
c8afcb9
test(http): cover reused route layer across servers
tim-smart Sep 24, 2026
fb0b09c
fix(http): register route layers once per router
tim-smart Sep 24, 2026
a712baa
style: format isolated server route test
tim-smart Sep 24, 2026
ce5eddb
docs(http): clarify memoized route registration caveat
tim-smart Sep 24, 2026
ed0af5a
test(http): cover direct router registration across servers
tim-smart Sep 24, 2026
b805a13
fix(http): fork app memo map per router entrypoint
tim-smart Sep 24, 2026
fe563a9
style(test): format server isolation regression
tim-smart Sep 24, 2026
c1a6082
docs(http): clarify entrypoint dependency sharing
tim-smart Sep 24, 2026
69861fb
Restore public HttpRouter.layer with isolated entrypoints
tim-smart Sep 24, 2026
7c6d700
Simplify router isolation with forked layer memo maps
tim-smart Sep 24, 2026
3602033
Trim router isolation tests and restore MCP fixture
tim-smart Sep 24, 2026
822af09
docs(http): clarify router isolation migration
tim-smart Sep 24, 2026
2b37f5f
wip
tim-smart Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/isolate-http-serve-routers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"effect": patch
---

Build each `HttpRouter` entrypoint with a fresh router in a forked layer memo map. Routes on separate servers and handlers no longer leak into each other.

Breaking changes:

- Routes registered on a router provided by the app itself (for example, via `Layer.provide(HttpRouter.layer)`) are not served; the entrypoint serves its own router. For RPC, put the protocol and its handlers in the app passed to `serve`, for example `HttpRouter.serve(RpcLayer.pipe(Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))))`.
- Layers first built inside an entrypoint are private to it. Providing the same stateful service to both the app and a sibling layer can build it twice depending on build order. Provide services that must be shared outside the entrypoint; services already built in the parent memo map are reused.
67 changes: 48 additions & 19 deletions packages/effect/src/http/HttpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface HttpRouter {
| Request.From<"GlobalError", Exclude<E, Types.unhandled>>
>

// @effect-diagnostics-next-line lazyEffect:off
readonly asHttpEffect: () => Effect.Effect<
HttpServerResponse.HttpServerResponse,
unknown,
Expand Down Expand Up @@ -598,6 +599,11 @@ export const layer: Layer.Layer<HttpRouter> = Layer.effect(HttpRouter)(make)
* `Scope`; route request markers are converted into the ordinary requirements of
* the returned handler.
*
* **Gotchas**
*
* The layer is built with a forked `MemoMap`, so calling `toHttpEffect` twice
* will build the same layer twice.
*
* @stability unstable
* @category converting
* @since 4.0.0
Expand All @@ -613,11 +619,13 @@ export const toHttpEffect = <A, E, R>(
Request.Without<E>,
Exclude<Request.Without<R>, HttpRouter> | Scope.Scope
> =>
Effect.gen(function*() {
const context = yield* Layer.build(Layer.provideMerge(appLayer, layer))
const router = Context.get(context, HttpRouter)
// @effect-diagnostics effect/returnEffectInGen:off
return router.asHttpEffect()
Effect.contextWith((context) => {
const scope = Context.get(context, Scope.Scope)
const memoMap = Layer.CurrentMemoMap.forkOrCreate(context)
return Effect.map(Layer.buildWithMemoMap(Layer.provideMerge(appLayer, layer), memoMap, scope), (context) => {
const router = Context.get(context, HttpRouter)
return router.asHttpEffect()
})
}) as any

const RouteTypeId = "~effect/http/HttpRouter/Route"
Expand Down Expand Up @@ -1265,6 +1273,16 @@ export const provideRequest =
/**
* Runs the provided application layer as an HTTP server.
*
* **Details**
*
* Layers first built inside the app are private to this server. Provide
* services that must be shared with sibling layers outside `serve`.
*
* **Gotchas**
*
* The layer is built with a forked `MemoMap`, so calling `serve` twice will
* build the same layer twice.
*
* @stability unstable
* @category layers
* @since 4.0.0
Expand Down Expand Up @@ -1305,17 +1323,25 @@ export const serve = <A, E, R, HE, HR = Request.Only<"Requires", R> | Request.On
if (options?.disableLogger !== true) {
middleware = middleware ? compose(middleware, HttpMiddleware.logger) : HttpMiddleware.logger
}
const RouterLayer = options?.routerConfig
? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig))
: layer
return Effect.gen(function*() {
const router = yield* HttpRouter
const handler = router.asHttpEffect()
return middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler)
const layerWithRouter = Layer.provideMerge(
appLayer,
options?.routerConfig
? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig))
: layer
)
return Effect.contextWith((context) => {
const memoMap = Layer.CurrentMemoMap.forkOrCreate(context)
return Layer.buildWithMemoMap(layerWithRouter, memoMap, Context.get(context, Scope.Scope))
}).pipe(
Effect.map((context) => {
const router = Context.get(context, HttpRouter)
const handler = router.asHttpEffect()
return Layer.merge(
middleware ? HttpServer.serve(handler, middleware) : HttpServer.serve(handler),
Layer.succeedContext(context)
)
}),
Layer.unwrap,
Layer.provideMerge(appLayer),
Layer.provide(RouterLayer),
options?.disableListenLog ? identity : HttpServer.withLogAddress
) as any
}
Expand Down Expand Up @@ -1393,9 +1419,12 @@ export const toWebHandler = <
const RouterLayer = options?.routerConfig
? Layer.provide(layer, Layer.succeed(RouterConfig)(options.routerConfig))
: layer
return HttpEffect.toWebHandlerLayerWith(Layer.provideMerge(appLayer, RouterLayer) as Layer.Layer<A | HttpRouter, E>, {
toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()),
middleware,
memoMap: options?.memoMap
})
return HttpEffect.toWebHandlerLayerWith(
Layer.provideMerge(appLayer, RouterLayer) as Layer.Layer<A | HttpRouter, E>,
{
toHandler: (s) => Effect.succeed(Context.get(s, HttpRouter).asHttpEffect()),
middleware,
memoMap: options?.memoMap
}
)
}
39 changes: 39 additions & 0 deletions packages/platform/node/test/NodeHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,45 @@ const IdParams = Schema.Struct({
const todoResponse = HttpServerResponse.schemaJson(Todo)

describe("HttpServer", () => {
it.effect("keeps routes isolated between independent servers", () =>
Effect.gen(function*() {
const publicServer = Http.createServer()
const internalServer = Http.createServer()

const Health = Layer.effectDiscard(
Effect.flatMap(HttpRouter.HttpRouter, (router) =>
router.add("GET", "/health", HttpServerResponse.text("healthy")))
)
const publicApp = Layer.mergeAll(
HttpRouter.add("GET", "/public", HttpServerResponse.text("public")),
Health
)
const internalApp = Layer.mergeAll(
HttpRouter.add("GET", "/internal", HttpServerResponse.text("internal")),
Health
)

yield* Layer.mergeAll(
HttpRouter.serve(publicApp, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provide(NodeHttpServer.layer(() =>
publicServer, { port: 0 }))
),
HttpRouter.serve(internalApp, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provide(NodeHttpServer.layer(() => internalServer, { port: 0 }))
)
).pipe(Layer.build)

const status = (port: number, path: string) =>
Effect.promise(() => fetch("http://localhost:" + port + path).then((response) => response.status))

assert.strictEqual(yield* status(tcpPort(publicServer), "/public"), 200)
assert.strictEqual(yield* status(tcpPort(internalServer), "/internal"), 200)
assert.strictEqual(yield* status(tcpPort(publicServer), "/health"), 200)
assert.strictEqual(yield* status(tcpPort(internalServer), "/health"), 200)
assert.strictEqual(yield* status(tcpPort(publicServer), "/internal"), 404)
assert.strictEqual(yield* status(tcpPort(internalServer), "/public"), 404)
}))

it.effect("schema", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
Expand Down
20 changes: 8 additions & 12 deletions packages/platform/node/test/RpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()),
Expand Down
Loading