diff --git a/.changeset/deep-books-stand.md b/.changeset/deep-books-stand.md new file mode 100644 index 00000000000..e34e9d5a09a --- /dev/null +++ b/.changeset/deep-books-stand.md @@ -0,0 +1,5 @@ +--- +"@effect/cluster": patch +--- + +Fix HttpRunner double-slash routing diff --git a/packages/cluster/src/HttpRunner.ts b/packages/cluster/src/HttpRunner.ts index 4ffcd4bd06a..2985882a4e3 100644 --- a/packages/cluster/src/HttpRunner.ts +++ b/packages/cluster/src/HttpRunner.ts @@ -23,6 +23,8 @@ import type { RunnerStorage } from "./RunnerStorage.js" import * as Sharding from "./Sharding.js" import type * as ShardingConfig from "./ShardingConfig.js" +const normalizePath = (path: string): string => path.startsWith("/") ? path : `/${path}` + /** * @since 1.0.0 * @category Layers @@ -43,7 +45,9 @@ export const layerClientProtocolHttp = (options: { return (address) => { const clientWithUrl = HttpClient.mapRequest( client, - HttpClientRequest.prependUrl(`http${https ? "s" : ""}://${address.host}:${address.port}/${options.path}`) + HttpClientRequest.prependUrl( + `http${https ? "s" : ""}://${address.host}:${address.port}${normalizePath(options.path)}` + ) ) return RpcClient.makeProtocolHttp(clientWithUrl).pipe( Effect.provideService(RpcSerialization.RpcSerialization, serialization) @@ -81,7 +85,7 @@ export const layerClientProtocolWebsocket = (options: { const constructor = yield* Socket.WebSocketConstructor return Effect.fnUntraced(function*(address) { const socket = yield* Socket.makeWebSocket( - `ws${https ? "s" : ""}://${address.host}:${address.port}/${options.path}` + `ws${https ? "s" : ""}://${address.host}:${address.port}${normalizePath(options.path)}` ).pipe( Effect.provideService(Socket.WebSocketConstructor, constructor) ) diff --git a/packages/cluster/test/HttpRunner.test.ts b/packages/cluster/test/HttpRunner.test.ts new file mode 100644 index 00000000000..ec8eb207fab --- /dev/null +++ b/packages/cluster/test/HttpRunner.test.ts @@ -0,0 +1,106 @@ +import * as HttpClient from "@effect/platform/HttpClient" +import * as HttpClientError from "@effect/platform/HttpClientError" +import { RpcSerialization } from "@effect/rpc" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Layer, Ref } from "effect" +import * as HttpRunner from "../src/HttpRunner.js" +import { RunnerAddress, Runners } from "../src/index.js" + +describe("HttpRunner", () => { + describe("layerClientProtocolHttp", () => { + const makeUrlCapturingClient = (urlRef: Ref.Ref>) => + HttpClient.make((request, url) => + Ref.update(urlRef, (urls) => [...urls, url.toString()]).pipe( + Effect.flatMap(() => + Effect.fail( + new HttpClientError.RequestError({ + request, + reason: "Transport", + cause: new Error("Mock - URL captured") + }) + ) + ) + ) + ) + + const testRequest = { + _tag: "Request" as const, + id: "1", + tag: "test", + payload: {}, + headers: [] as ReadonlyArray<[string, string]> + } + + it.scoped("path '/' produces http://host:port/", () => + Effect.gen(function*() { + const urlRef = yield* Ref.make>([]) + + const layer = HttpRunner.layerClientProtocolHttp({ path: "/" }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeUrlCapturingClient(urlRef))), + Layer.provide(RpcSerialization.layerNdjson) + ) + + const makeProtocol = yield* Effect.provide(Runners.RpcClientProtocol, layer) + const protocol = yield* makeProtocol(RunnerAddress.make("localhost", 3000)) + + yield* protocol.send(testRequest).pipe(Effect.ignore) + + const urls = yield* Ref.get(urlRef) + expect(urls[0]).toBe("http://localhost:3000/") + })) + + it.scoped("path '' produces http://host:port/", () => + Effect.gen(function*() { + const urlRef = yield* Ref.make>([]) + + const layer = HttpRunner.layerClientProtocolHttp({ path: "" }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeUrlCapturingClient(urlRef))), + Layer.provide(RpcSerialization.layerNdjson) + ) + + const makeProtocol = yield* Effect.provide(Runners.RpcClientProtocol, layer) + const protocol = yield* makeProtocol(RunnerAddress.make("localhost", 3000)) + + yield* protocol.send(testRequest).pipe(Effect.ignore) + + const urls = yield* Ref.get(urlRef) + expect(urls[0]).toBe("http://localhost:3000/") + })) + + it.scoped("path '/rpc' produces http://host:port/rpc", () => + Effect.gen(function*() { + const urlRef = yield* Ref.make>([]) + + const layer = HttpRunner.layerClientProtocolHttp({ path: "/rpc" }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeUrlCapturingClient(urlRef))), + Layer.provide(RpcSerialization.layerNdjson) + ) + + const makeProtocol = yield* Effect.provide(Runners.RpcClientProtocol, layer) + const protocol = yield* makeProtocol(RunnerAddress.make("localhost", 3000)) + + yield* protocol.send(testRequest).pipe(Effect.ignore) + + const urls = yield* Ref.get(urlRef) + expect(urls[0]).toBe("http://localhost:3000/rpc") + })) + + it.scoped("path 'rpc' produces http://host:port/rpc", () => + Effect.gen(function*() { + const urlRef = yield* Ref.make>([]) + + const layer = HttpRunner.layerClientProtocolHttp({ path: "rpc" }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeUrlCapturingClient(urlRef))), + Layer.provide(RpcSerialization.layerNdjson) + ) + + const makeProtocol = yield* Effect.provide(Runners.RpcClientProtocol, layer) + const protocol = yield* makeProtocol(RunnerAddress.make("localhost", 3000)) + + yield* protocol.send(testRequest).pipe(Effect.ignore) + + const urls = yield* Ref.get(urlRef) + expect(urls[0]).toBe("http://localhost:3000/rpc") + })) + }) +})