Repository: turbopuffer/turbopuffer-typescript
Pull request #54: Baseurl can have path
Author: login: Arash-11
Created: 2025-03-07T22:55:06Z | Merged: 2025-03-08T00:29:19Z



Diff:
diff --git a/package-lock.json b/package-lock.json
index e17544cf..54e80307 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@turbopuffer/turbopuffer",
-  "version": "0.6.9",
+  "version": "0.6.10",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@turbopuffer/turbopuffer",
-      "version": "0.6.9",
+      "version": "0.6.10",
       "license": "MIT",
       "dependencies": {
         "pako": "^2.1.0",
diff --git a/package.json b/package.json
index e1b2465d..10dbfc5b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@turbopuffer/turbopuffer",
-  "version": "0.6.9",
+  "version": "0.6.10",
   "description": "Official Typescript API client library for turbopuffer.com",
   "scripts": {
     "build": "rm -rf dist && tsc",
diff --git a/src/helpers.ts b/src/helpers.ts
index 7fd8ffbe..9b94a2a3 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -55,6 +55,32 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function buildUrl(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
+  // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references
+  // if baseUrl doesn't end in /, add one to make it behave
+  // like a directory so the next path part is appended.
+  // if there are multiple / appended, ensure all but one get removed.
+  const updatedBaseUrl = baseUrl.replace(/\/*$/, "/");
+
+  // strip leading slashes from `path` so it's appended rather
+  // than treated as absolute
+  const relativePath = path.replace(/^\/+/, "");
+
+  const url = new URL(relativePath, updatedBaseUrl);
+
+  if (query) {
+    for (const [key, value] of Object.entries(query)) {
+      if (value) url.searchParams.set(key, value);
+    }
+  }
+
+  return url;
+}
+
 /** A helper function to determine if a status code should be retried. */
 export function statusCodeShouldRetry(statusCode?: number): boolean {
   return (
diff --git a/src/httpClient/default.ts b/src/httpClient/default.ts
index 32a1f2ca..724c0f2a 100644
--- a/src/httpClient/default.ts
+++ b/src/httpClient/default.ts
@@ -6,6 +6,7 @@ import {
   statusCodeShouldRetry,
   delay,
   make_request_timing,
+  buildUrl,
 } from "../helpers";
 
 function convertHeadersType(headers: Headers): Record<string, string> {
@@ -58,19 +59,7 @@ export default class DefaultHTTPClient implements HTTPClient {
     compress,
     retryable,
   }: RequestParams): RequestResponse<T> {
-    const url = new URL(path, this.baseUrl);
-    if (query) {
-      Object.keys(query).forEach((key) => {
-        const value = query[key];
-        if (value) {
-          url.searchParams.append(key, value);
-        }
-      });
-    }
-    path = url.pathname;
-    if (query) {
-      path += url.search;
-    }
+    const url = buildUrl(this.baseUrl, path, query);
 
     const headers: Record<string, string> = {
       Authorization: `Bearer ${this.apiKey}`,
@@ -106,7 +95,7 @@ export default class DefaultHTTPClient implements HTTPClient {
       error = null;
       request_start = performance.now();
       try {
-        response = await fetch(new URL(path, this.origin), {
+        response = await fetch(url, {
           method,
           headers,
           body: requestBody,
diff --git a/src/httpClient/node.ts b/src/httpClient/node.ts
index b051b5fc..0678c758 100644
--- a/src/httpClient/node.ts
+++ b/src/httpClient/node.ts
@@ -15,6 +15,7 @@ import {
   statusCodeShouldRetry,
   delay,
   make_request_timing,
+  buildUrl,
 } from "../helpers";
 
 const gzipAsync = promisify(gzip);
@@ -100,19 +101,7 @@ export default class NodeHTTPClient implements HTTPClient {
     compress,
     retryable,
   }: RequestParams): RequestResponse<T> {
-    const url = new URL(path, this.baseUrl);
-    if (query) {
-      Object.keys(query).forEach((key) => {
-        const value = query[key];
-        if (value) {
-          url.searchParams.append(key, value);
-        }
-      });
-    }
-    path = url.pathname;
-    if (query) {
-      path += url.search;
-    }
+    const url = buildUrl(this.baseUrl, path, query);
 
     const headers: Record<string, string> = {
       Authorization: `Bearer ${this.apiKey}`,
@@ -149,8 +138,8 @@ export default class NodeHTTPClient implements HTTPClient {
       request_start = performance.now();
       try {
         response = await this.agent.request({
-          origin: this.origin,
-          path,
+          origin: url.origin,
+          path: url.pathname + url.search,
           method: method as Dispatcher.HttpMethod,
           headers,
           body: requestBody,
diff --git a/src/turbopuffer.test.ts b/src/turbopuffer.test.ts
index 9c264bd3..8a6a952e 100644
--- a/src/turbopuffer.test.ts
+++ b/src/turbopuffer.test.ts
@@ -1,5 +1,5 @@
 import { Turbopuffer, RankBy, Schema, TurbopufferError } from "./index";
-import { isRuntimeFullyNodeCompatible } from "./helpers";
+import { isRuntimeFullyNodeCompatible, buildUrl } from "./helpers";
 
 const tpuf = new Turbopuffer({
   apiKey: process.env.TURBOPUFFER_API_KEY!,
@@ -880,3 +880,203 @@ test("product_operator", async () => {
     expect(results.length).toBeGreaterThan(0);
   }
 });
+
+// test helper and utility methods
+test("test_buildUrl", () => {
+  /** baseUrl w/o path **/
+  /* w/o path + w/o query */
+  expect(
+    buildUrl("https://gcp-us-east4.turbopuffer.com", "").href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/");
+
+  /* w/o path + w/ query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com",
+      "/v1/namespaces",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      }
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  /* w/ path + w/o query */
+  expect(
+    buildUrl("https://gcp-us-east4.turbopuffer.com", "/v1/namespaces").href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  expect(
+    buildUrl("https://gcp-us-east4.turbopuffer.com", "v1/namespaces").href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  expect(
+    buildUrl("https://gcp-us-east4.turbopuffer.com", "v1/namespaces/").href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces/");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  expect(
+    buildUrl("https://gcp-us-east4.turbopuffer.com/", "v1/namespaces").href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com//",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com//",
+      "//v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces");
+
+  /* w/ path + w/ query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com",
+      "/v1/namespaces",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      }
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/v1/namespaces?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  /** baseUrl w/ path **/
+  /* w/o path + w/o query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path",
+      "",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/");
+
+  /* w/o path + w/ query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path",
+      "",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/",
+      "",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  /* w/ path + w/o query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path//",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path//",
+      "v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces");
+
+  /* w/ path + w/ query */
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path//",
+      "v1/namespaces",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path//",
+      "v1/namespaces",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/v1/namespaces?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  /** baseUrl w/ double path **/
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path",
+      "",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path/");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path",
+      "",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path/?cursor=next_cursor&prefix=my_prefix&page_size=15");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path",
+      "/v1/namespaces",
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path/v1/namespaces");
+
+  expect(
+    buildUrl(
+      "https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path",
+      "/v1/namespaces",
+      {
+        cursor: 'next_cursor',
+        prefix: 'my_prefix',
+        page_size: '15',
+      },
+    ).href,
+  ).toEqual("https://gcp-us-east4.turbopuffer.com/my-cool-path/another-dope-path/v1/namespaces?cursor=next_cursor&prefix=my_prefix&page_size=15");
+});


Reviews:
user: login: morgangallant
body: LGTM; nice tests here!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T23:13:42Z
user: login: morgangallant
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T23:14:45Z
user: login: pushrax
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T23:19:11Z
user: login: morgangallant
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T23:29:19Z
user: login: morgangallant
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T23:31:14Z
user: login: pushrax
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T23:35:24Z
user: login: Arash-11
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T23:47:34Z
user: login: Arash-11
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T23:48:27Z
user: login: Arash-11
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T23:48:54Z

Review comments:
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
path: src/helpers.ts
user: login: morgangallant
body: Only nit here is this could maybe be better named + have a doc comment explaining what it's doing
created_at: 2025-03-07T23:14:45Z
author_association: COLLABORATOR
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
path: src/helpers.ts
user: login: pushrax
body: agree about the naming, what's being updated here?
created_at: 2025-03-07T23:19:03Z
author_association: MEMBER
diff_hunk: @@ -106,7 +95,7 @@ export default class DefaultHTTPClient implements HTTPClient {
       error = null;
       request_start = performance.now();
       try {
-        response = await fetch(new URL(path, this.origin), {
+        response = await fetch(new URL(updatedPath, this.origin), {
path: src/httpClient/default.ts
user: login: morgangallant
body: I think this might be problematic, especially if the origin contains a path segment
created_at: 2025-03-07T23:29:19Z
author_association: COLLABORATOR
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
path: src/helpers.ts
user: login: morgangallant
body: could def. use a PR description; this specifically is adding support for path segments in the baseUrl (something we used to support; but since regressed at v0.6.5)
created_at: 2025-03-07T23:31:14Z
author_association: COLLABORATOR
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
+  // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references
+  // if baseUrl doesn't end in /, add one to make it behave
+  // like a directory so the next path part is appended.
+  // if there are multiple / appended, ensure all but one get removed.
+  let updatedBaseUrl = baseUrl.replace(/\/+$/, "");
+  updatedBaseUrl += "/";
path: src/helpers.ts
user: login: pushrax
body: could do this and the previous operation in one call `baseUrl.replace(/\/*$/, "/")`
created_at: 2025-03-07T23:35:17Z
author_association: MEMBER
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
+  // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references
+  // if baseUrl doesn't end in /, add one to make it behave
+  // like a directory so the next path part is appended.
+  // if there are multiple / appended, ensure all but one get removed.
+  let updatedBaseUrl = baseUrl.replace(/\/+$/, "");
+  updatedBaseUrl += "/";
path: src/helpers.ts
user: login: Arash-11
body: this only works if the baseUrl already ends in a `/` (i'll add a comment to make it clear!)
created_at: 2025-03-07T23:47:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,7 +95,7 @@ export default class DefaultHTTPClient implements HTTPClient {
       error = null;
       request_start = performance.now();
       try {
-        response = await fetch(new URL(path, this.origin), {
+        response = await fetch(new URL(updatedPath, this.origin), {
path: src/httpClient/default.ts
user: login: Arash-11
body: i like the idea of just building the url instead of getting the updated path and then awkwardly trying to fit that into a `new URL()`!
created_at: 2025-03-07T23:48:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +55,37 @@ export class TurbopufferError extends Error {
   }
 }
 
+export function getUpdatedUrlPath(
+  baseUrl: string,
+  path: string,
+  query?: Record<string, string | undefined>,
+) {
+  // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references
+  // if baseUrl doesn't end in /, add one to make it behave
+  // like a directory so the next path part is appended.
+  // if there are multiple / appended, ensure all but one get removed.
+  let updatedBaseUrl = baseUrl.replace(/\/+$/, "");
+  updatedBaseUrl += "/";
path: src/helpers.ts
user: login: Arash-11
body: ah my bad, yes the `*`, not `+`
created_at: 2025-03-07T23:48:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: morgangallant
created_at: 2025-03-07T23:28:51Z
body: Took a second look here, would be good to add some `new URL()` tests to make sure this is what we want — maybe as a way to simplify this, maybe this function should be `buildUrl`, taking a baseUrl, path and a query!
author_association: COLLABORATOR
user: login: morgangallant
created_at: 2025-03-08T00:28:01Z
body: nice:) seems good to merge!
author_association: COLLABORATOR