From 29bc107c97c584e628e349c084fb7a4eba7add15 Mon Sep 17 00:00:00 2001 From: Osama Ansar Date: Fri, 4 Sep 2026 13:21:41 +0500 Subject: [PATCH] fix(uri): keep a trailing slash on the base URL so proxied sub-paths survive `encodeBaseUrl` returned a base URL whose path had no trailing slash (e.g. `https://host/db/prod`). The Hrana client resolves endpoint paths against it with `new URL("v2/pipeline", baseUrl)`, and per the WHATWG URL resolver that replaces the last path segment when the base does not end in `/`. So a client pointed at a libSQL server behind a reverse proxy on a sub-path sent every request one segment short (`https://host/db/v2/pipeline` instead of `https://host/db/prod/v2/pipeline`), which is why appending a `/` to the URL was the known workaround. Append a trailing slash to a non-empty path in `encodeBaseUrl` so the base URL is safe for relative resolution. The root case (`""` / `"/"`) is unchanged. The `wss:`/`ws:` clients get the same normalized URL; the Hrana WebSocket handshake is not sensitive to the trailing slash. Closes #296 --- .../libsql-client/src/__tests__/uri.test.ts | 30 +++++++++++++++++-- packages/libsql-core/src/uri.ts | 7 +++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/libsql-client/src/__tests__/uri.test.ts b/packages/libsql-client/src/__tests__/uri.test.ts index 6ea18b62..71dc2f91 100644 --- a/packages/libsql-client/src/__tests__/uri.test.ts +++ b/packages/libsql-client/src/__tests__/uri.test.ts @@ -216,13 +216,13 @@ test("encodeBaseUrl()", () => { scheme: "http", host: "localhost", path: "/foo/bar", - url: "http://localhost/foo/bar", + url: "http://localhost/foo/bar/", }, { scheme: "http", host: "localhost", path: "foo/bar", - url: "http://localhost/foo/bar", + url: "http://localhost/foo/bar/", }, { scheme: "http", @@ -262,7 +262,7 @@ test("encodeBaseUrl()", () => { userinfo: { username: "alice", password: "secret" }, port: 8080, path: "/some/path", - url: "https://alice:secret@localhost:8080/some/path", + url: "https://alice:secret@localhost:8080/some/path/", }, ]; @@ -272,3 +272,27 @@ test("encodeBaseUrl()", () => { ).toStrictEqual(new URL(url)); } }); + +test("encodeBaseUrl() keeps the full path when the Hrana client resolves an endpoint against it", () => { + // Regression for #296: a deployment proxied under a sub-path lost its last + // path segment because `new URL("v2/pipeline", base)` drops it unless the + // base path ends with "/". + const authority = { + host: "example.com", + port: undefined, + userinfo: undefined, + }; + + const base = encodeBaseUrl("https", authority, "/some/sub/path"); + expect(new URL("v2/pipeline", base).href).toBe( + "https://example.com/some/sub/path/v2/pipeline", + ); + expect(new URL("v3-protobuf/pipeline", base).href).toBe( + "https://example.com/some/sub/path/v3-protobuf/pipeline", + ); + + const root = encodeBaseUrl("https", authority, ""); + expect(new URL("v2/pipeline", root).href).toBe( + "https://example.com/v2/pipeline", + ); +}); diff --git a/packages/libsql-core/src/uri.ts b/packages/libsql-core/src/uri.ts index 470df4ef..203ee5dd 100644 --- a/packages/libsql-core/src/uri.ts +++ b/packages/libsql-core/src/uri.ts @@ -176,6 +176,13 @@ export function encodeBaseUrl( if (pathText !== "" && !pathText.startsWith("/")) { pathText = "/" + pathText; } + // The Hrana client resolves endpoint paths ("v2/pipeline", ...) against this + // URL with `new URL(endpointPath, baseUrl)`, which replaces the last path + // segment unless the base path ends with "/". Keep the trailing slash so a + // deployment served under a proxied sub-path does not lose that segment. + if (pathText !== "" && !pathText.endsWith("/")) { + pathText += "/"; + } return new URL(`${schemeText}${authorityText}${pathText}`); }